diff --git a/.github/skills/code-coverage-calculation/SKILL.md b/.github/skills/code-coverage-calculation/SKILL.md new file mode 100644 index 000000000..9862a3f52 --- /dev/null +++ b/.github/skills/code-coverage-calculation/SKILL.md @@ -0,0 +1,294 @@ +--- +name: code-coverage-calculation +description: >- + EXPERIMENTAL. Calculate code coverage of a WinUI control or component (for example + "calculate coverage of control TitleBar") by running only that component's tests on a local + Hyper-V VM against the local build. Supports controls in Microsoft.UI.Xaml.Controls.dll and + Microsoft.UI.Xaml.Controls.Tabular.dll, and dxaml types in Microsoft.ui.xaml.dll. Reports per-file + and per-function line coverage that matches + the ADO Code Coverage tab. Use for "calculate/measure/get coverage of ", "how much do + 's tests cover", or "coverage before/after my tests" when you have a local build and a VM. +--- + +# WinUI component coverage from a local VM test run + +## Agent workflow (follow in order; every gate is mandatory) + +All commands run from the repo root. `` = `.github\skills\code-coverage-calculation`. + +### Gate 0 - Prerequisites (check; stop and tell the user on failure) + +| Check | How | If missing | +|---|---|---| +| Local build of the flavor (default `amd64chk`), from the current branch | The module DLL exists under `BuildOutput\bin\amd64chk\Product\`, and for MUXC/Tabular the link log `BuildOutput\obj\amd64chk\controls\dev\dll` (or `dll-tabular`) `\**\link.command.1.tlog` exists | Ask before building (10+ min), then run `.\initrun.ps1 .\build.cmd /q` from the repo root. | +| VS Enterprise code coverage | `Microsoft.CodeCoverage.Console.exe` under `%ProgramFiles%\Microsoft Visual Studio` | Stop; the user must install it. | +| `dotnet-coverage` | `Get-Command dotnet-coverage` | Tell the user: `dotnet tool install --global dotnet-coverage` | +| Hyper-V rights | `Get-VM` succeeds | Stop. Ask the user to add their account (from `whoami`) to **Hyper-V Administrators** in an elevated PowerShell window, then sign out and back in. Never elevate or add group members yourself. | + +### Gate 1 - VM details (strict: never start a run without them) + +**Before every coverage run, the user must explicitly confirm the VM or enter its details in this +session.** Never pick a VM silently, even if only one credential is cached. Never reuse a VM name, +username or password from memory, an earlier session, or an earlier run without asking again. + +1. Look for cached credentials. Match them against the real VM names, because the cache file name + replaces non-alphanumeric characters with `_` and can't be turned back into the VM name. This + shows the username only, never the password: + ```powershell + Get-VM | ForEach-Object { + $f = Join-Path $env:USERPROFILE ".winui-test\vmcred-$($_.Name -replace '[^a-zA-Z0-9]','_').xml" + if (Test-Path $f) { [pscustomobject]@{ VM = $_.Name; State = $_.State; UserName = (Import-Clixml $f).UserName; CacheFile = Split-Path $f -Leaf } } + } + ``` + If two VMs share one `CacheFile` (for example `Test-VM` and `Test_VM`), don't offer the cache for + either; ask for details and re-enter the password (`-Force`). +2. Use `ask_user` every time: + - **Cached credentials exist:** ask "Is this the VM to use?" Offer one choice per VM and + username listed above, plus **"Enter different VM details"**. + - **No cache, or the user chooses different details:** ask for the **VM name** and **VM username** + (the plain account name as shown on the VM, with no `.\` or `VM\` prefix). + - If the user declines or cancels, stop. Do not continue with a default. +3. Run `tools\Set-VmCredential.ps1 -VMName -UserName ` (a shared repo tool): + - For new details, add `-Force`. The script asks for the **password in a separate window** (never in + chat; chat text is kept in session history) and writes the encrypted per-user cache + `%USERPROFILE%\.winui-test\vmcred-.xml`. This is the cache `tools\run-tests-on-vm.ps1` and Steps 3-4 read; + Step 3 does not pass `-Credential` to the runner, so the cache must exist. + - For a confirmed cached VM, run it without `-Force`. It validates without prompting. + - Either way it checks that the VM exists and is Running, that the logon works, and that the user has an + Active desktop session. A new credential replaces the cache only after it validates. +4. If the script fails (VM missing or stopped, bad password, no active desktop), report the message, + ask the user to fix it or give new details, and repeat this gate. Do not continue. + +### Gate 2 - Resolve the component and its tests + +**A. `controls\dev` (`Microsoft.UI.Xaml.Controls.dll` or `Microsoft.UI.Xaml.Controls.Tabular.dll`)** + +1. Folder: match the name case-insensitively against `controls\dev\*` folders. If there's no match, + search `controls\dev\*\*.idl` for `runtimeclass ` and use that folder (for example + `ItemsRepeater` resolves to `Repeater`). If there are zero or several candidates, ask the user and offer the candidates. + **Module:** if `controls\Tabular.ProjectImports.targets` imports `dev\\.vcxitems` (today: TableView, + TabularShaping, ShapedItemsSource, GroupedSourceAdapter, HierarchicalSourceAdapter, SortIndicator, + ResizeGripper), the code is in `Microsoft.UI.Xaml.Controls.Tabular.dll`: pass + `-Module Microsoft.UI.Xaml.Controls.Tabular.dll`. Otherwise it's `Microsoft.UI.Xaml.Controls.dll` (the default). +2. Tests: list the `[TestClass]` classes (with their namespaces) in `controls\dev\\APITests\**\*.cs` + and `controls\dev\\InteractionTests\**\*.cs`. TAEF test names are `..`, + and `runtests` selects `@Name='*'`. + - Prefer one wildcard that covers every class, usually `*.Tests.*`. It covers both the `ApiTests` + and `InteractionTests` classes, and namespaces such as `...ApiTests.RepeaterTests.*`. + - If no single wildcard covers them all, pass several filters (`-TestFilter 'a','b'`). Each runs + with its own collector, and the results are merged and deduplicated in the report. + - If the folder has no tests, tell the user and ask which tests to run. +3. `-ComponentPathRegex`: take every `runtimeclass` `` declared in `controls\dev\\*.idl` and match + the folder plus both kinds of generated sources: + `\\controls\\dev\\\\|\\controls\\dev\\Generated\\(||...)\.|\\dll(-tabular)?\\generated\\(.+\\)?(||...)\.g\.` + The second part covers `controls\dev\Generated\.properties.*` and the third covers the build-generated + `BuildOutput\obj\\controls\dev\dll\generated\...\.g.cpp/.h` (or `dll-tabular\generated`) files. +4. Show the resolved plan (folder, module, test classes, filter(s), regex), then proceed. Ask only if + something is ambiguous. +5. The build must contain those tests. Before instrumenting, the orchestrator lists the matching tests in + `TestPayload\` with TAEF (the same selection `runtests` uses on the VM) and stops in seconds if a + filter matches none. That usually means `BuildOutput` was built from an older commit or another branch. + Tell the user, ask before rebuilding (`.\initrun.ps1 .\build.cmd /q`), then rerun with `-RefreshPayload`. + +**B. Not in `controls\dev`: investigate `dxaml` (MUX, `Microsoft.ui.xaml.dll`) and get approval** + +1. Sources: search `dxaml\xcp` for the type, for example `core\core\elements\.cpp`, `core\inc\.h`, + `dxaml\lib\_Partial.*`, `dxaml\lib\winrtgeneratedclasses\.g.*`, `components\**`, and + `core\controls\generated\C*`. Decide which are in scope (for example whether `ButtonBase` counts for `Button`). +2. Tests: `dxaml\test\native\external\\\` (C++ TAEF; names look like + `Microsoft::UI::Xaml::Tests::Controls::::::`). Note each class's `Hosting:Mode`; + `runtests` cannot mix hosting modes in one filter, so use one filter per mode. +3. Use `ask_user` to present the proposed source files and regex, test classes and filter(s), and module + `Microsoft.ui.xaml.dll`. Warn that MUX is about 71 MB, so the first instrumentation is likely to take + well over 40 minutes. **Do not run until the user approves.** Revise the plan with their feedback. + +**C. Found in neither place:** ask the user which source folder and tests to use. Other DLLs (for example +`Microsoft.UI.Xaml.Phone.dll`) aren't supported yet; say so instead of measuring a different module. + +### Tell the user what the run does on the VM (before every run) + +The run makes changes on the test VM, so say this once before starting: +- It creates `C:\WinUICoverage` (coverage tools, logs and output) and runs short-lived scheduled tasks + as the VM user. With `-MoveSessionToConsole` it also runs `tscon` as SYSTEM to move the session to the console. +- It opens the coverage collector's named pipe (`\\.\pipe\CodeCoverage.pipe.`) with a NULL DACL, so + low-integrity test processes can report coverage. **Any local user on the VM can connect to that pipe + until the run ends.** This is intended for a dedicated test VM; don't use it on a shared machine. +- It temporarily replaces the module in the local `TestPayload` and in the VM's deployed payload with the + instrumented copy, and restores both afterwards (hash-checked). `BuildOutput` and sources are never changed. + +### Run + +- Output: `\BuildOutput\Coverage\\` by default (git-ignored with `BuildOutput`). + Its `work\` folder holds the relinked and instrumented DLLs and the payload backups (several hundred MB). +- Add `-RefreshPayload` if `TestPayload\x64chk` is missing or older than the build output. A failed + refresh stops the run. +- Add `-MoveSessionToConsole` when the host is an RDP or Cloud PC session (`$env:SESSIONNAME -like 'RDP-*'`). +- Tell the user the expected time first (about 30-60 min the first time for MUXC, mostly instrumentation; a few + minutes for the 6.5 MB Tabular DLL; about 6-10 min with the cache). + Run it async with a long wait. **Never stop the process during Step 2 or 3**; the restore runs in `finally`, + and if a run is killed anyway, the next run restores the payload before doing anything else. + +Run from the repo root in PowerShell, invoking the script directly. Don't use `pwsh -File`: it turns +`'a','b'` into a single string. + +```powershell +& .\.github\skills\code-coverage-calculation\scripts\Invoke-WinUIVmCoverage.ps1 ` + -RepoRoot (Get-Location).Path -VMName -TestFilter ''[,''] ` + -ComponentName -ComponentPathRegex '' ` + -Module ` + [-RefreshPayload] [-MoveSessionToConsole] +``` + +### Report + +- If restoring the payload or VM fails, the command **fails** (after writing the report, if coverage was + collected) and `instrument-state.json` is kept. Tell the user prominently, and offer to rerun + `\scripts\Step4-Restore-Payload.ps1 -RepoRoot -WorkDir \work -VMName ` + (or simply rerun the coverage command, which restores first). +- From `report-\coverage-summary.md`, present the component total, the per-file table, and the + uncovered and partially covered functions. Point to `TreeReport\coverage-tree.html` and the CSVs. +- Check the test summary. If `Total=0`, the filter matched nothing; fix the filter instead of reporting 0 %. + +--- + +# Reference + +Measures **which lines of one component your selected tests execute**. It doesn't need a pipeline +run: it uses your local build and the same Hyper-V VM and runner (`tools\run-tests-on-vm.ps1`) you +already use for WinUI tests. Everything it needs is in this folder. + +## How it works + +- **Instrumentable MUXC:** static native instrumentation needs CV and FIXUP debug info + (`/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO`), which a chk MUXC link doesn't emit. Step 1 relinks a + copy with those flags from the recorded link command, without touching the repo or `BuildOutput`. +- **Collection:** the DLL is instrumented once with a per-module session id and copied over every + payload copy, together with `static_covrun*.dll`. On the VM, a server-mode collector listens on a named pipe + that `lib\Set-CoveragePipeAcl.ps1` opens to low-integrity test processes. Shutting the collector down + flushes the `.coverage` file. +- **Accurate numbers:** merging native `.coverage` files to Cobertura repeats every line once per input + file, so naive totals undercount coverage. `lib\Repair-CoverageDuplicates.ps1` and + `lib\Split-CoverageByModule.ps1` keep one entry per (file, line), which matches the Azure DevOps Code + Coverage tab. `lib\Get-FunctionCoverage.ps1` adds per-function detail from the native XML merge, and the + `Build-*TreeReport.ps1` scripts render single-file HTML reports. The PDB is only needed at instrument time. + +## Quick start + +```powershell +cd \.github\skills\code-coverage-calculation\scripts +.\Invoke-WinUIVmCoverage.ps1 ` + -RepoRoot ` + -VMName ` + -TestFilter '*.TitleBarTests.*' ` + -ComponentName TitleBar ` + -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` + -MoveSessionToConsole ` + -BaselinePerFileCsv '\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv' ` + -BaselineLabel 'Full test pass' +``` + +Output goes to `\BuildOutput\Coverage\amd64chk\TitleBar` unless you pass `-OutputDir`. If the baseline came from another checkout, also pass `-BaselineRepoRoot `. + +Open `\report-\coverage-summary.md`. Other outputs in that folder: +- `component-per-file.csv` +- `component-functions.csv` +- `TreeReport\coverage-tree.html` +- `PerModule\\corrected-per-file.csv` (whole module) + +## Prerequisites + +- **Local build:** a full local build of the flavor you test (default `amd64chk`), so that `BuildOutput\obj\...\link.command.1.tlog`, the objs and the PDB exist. Also an existing `TestPayload\x64chk`, created by any earlier `tools\run-tests-on-vm.ps1` run, or pass `-RefreshPayload`. +- **Visual Studio Enterprise (host):** supplies `Microsoft.CodeCoverage.Console.exe` for instrument, collect and shutdown, and the MSVC toolset that built the objs. +- **`dotnet-coverage`:** `dotnet tool install --global dotnet-coverage`, used for the merge. +- **VM access:** + - A running Hyper-V VM with the test user logged on, an unlocked interactive desktop, and the WinUI test prerun already done (any earlier `run-tests-on-vm.ps1` run does this). + - The host account must be a local admin or a member of Hyper-V Administrators. + - The VM credential must be cached in `%USERPROFILE%\.winui-test\vmcred-.xml`, the same cache `run-tests-on-vm.ps1` uses. +- **No VS or dotnet needed on the VM:** the collector tools are staged to `C:\WinUICoverage\CoverageTool` automatically. + +## What it does (and why) + +| Step | Script | Notes | +|---|---|---| +| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC and Tabular** (`Microsoft.UI.Xaml.Controls.Tabular.dll`, relinked from `controls\dev\dll-tabular`). chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. It needs `/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO` at link time, and this skill never edits repo files, so the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | +| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20-50 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up every payload copy and writes `instrument-state.json` **before** overwriting anything (so an interrupted run can always be restored), then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. | +| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `. `-SkipPayload` only stops the runner from regenerating `TestPayload` from BuildOutput (which would overwrite the instrumented DLLs); the runner still copies the changed payload files to the VM and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, downloads it and the collector log (also when the run failed), and closes the VM session. | +| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`, and again at the start of the next run if a previous run was killed before restoring. It restores every original DLL (copies that were never overwritten are skipped), hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. If any VM copy doesn't match afterwards, it fails and keeps `instrument-state.json`, so you can simply run it again. A restore failure makes the whole command fail. | +| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative: the checkout root (`-RepoRoot`, or `-BaselineRepoRoot` for a baseline from another checkout) or a build-agent root such as `C:\__w\1\s\` is removed, the report warns if the baseline matches none or under half of the component files, and `x86chk` versus `amd64chk` doesn't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | + +You can run the steps one at a time. Each writes its state or results under `\work`. + +## Timing + +- **First run:** about 30-60 min, almost all of it Step 2 instrumenting a 38 MB chk MUXC (20-50 min; it runs on a single core). The rest is a few seconds to relink, then deploy, the tests, flush and the report. +- **Tabular (6.5 MB):** instrumentation takes only a few minutes. +- **Later runs with an unchanged build:** about 6-10 min. The relink and the instrumented DLL are both reused from `\work`. +- **Report:** parsing the Cobertura and native XML takes about 1-2 min each; skip the second with `-SkipFunctionCoverage`. + +## Verified result (TitleBar, 2026-10-06) + +`-TestFilter '*.TitleBarTests.*'` on a local Hyper-V VM (Windows 11, amd64chk): 24/24 tests passed, and the collector produced an 88 MB `.coverage` file. These are the numbers from the full skill run, next to a baseline from a CI coverage run of all tests: + +| File | TitleBar tests only (this skill) | CI coverage run (all tests) | +|---|---|---| +| `controls\dev\TitleBar\TitleBar.cpp` | **98.07 %** (507/517) | 10.64 % (55/517) | +| `controls\dev\TitleBar\TitleBarAutomationPeer.cpp` | **100 %** (17/17) | 0 % (0/17) | +| `controls\dev\Generated\TitleBar.properties.cpp` | **90.23 %** (157/174) | 28.74 % (50/174) | +| All 15 TitleBar source files | **81.03 %** (773/954) | 23.69 % (226/954) | + +Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, and 1 not at all (`OnWindowRectChanged`). Afterwards, the payload, the VM and BuildOutput were all verified back at their original hashes. + +**Tabular module (TableView, 2026-10-20):** `-Module Microsoft.UI.Xaml.Controls.Tabular.dll -TestFilter '*.TableViewSortingTests.*'`: 35/35 tests passed in 3 min 41 s end to end (instrumenting the 6.5 MB DLL took 45 s). From that one test class, TableView was at 3557/11779 lines (30.2 %), with `TableView.cpp` at 63.8 %. The payload and the VM were restored afterwards. + +## Gotchas (learned the hard way) + +- **Host session disconnects (Cloud PC / RDP):** if the host's session disconnects, the Hyper-V *enhanced session* stops delivering input. UI tests then fail with `InjectMouseInput ... Access is denied` and "Test window lost focus". Use `-MoveSessionToConsole`, which runs `tscon /dest:console` as SYSTEM inside the VM, so the VM desktop stays interactive. Reconnecting with vmconnect moves the session back. +- **Instrumenting is slow:** about 20-50 min and about 3 GB RAM for chk MUXC. Don't interrupt it; later runs reuse the cache. Use `-ForceReinstrument` after a rebuild if the hash didn't change but you want a new session id. +- **Stale build:** if `BuildOutput` comes from another branch or an older commit, the payload may not contain the component's tests. The test check before Step 1 catches this; without it, TAEF reports "The selection criteria did not match any tests" on the VM and Step 3 fails after the long instrumentation. +- **Incremental deploy manifest:** `tools\run-tests-on-vm.ps1` only copies files whose size or timestamp changed since its last deploy (`TestPayload\.deploy-manifest--.json`). Step 4 restores the VM behind its back, and `static_covrun*.dll` always keep the tool's timestamp, so Step 3 removes the instrumented files from that manifest before every run. Without that, a rerun silently tests the original DLL (empty coverage), or the instrumented DLL is deployed without `static_covrun64.dll` and the test app crashes with "ClassFactory cannot supply requested class". +- **Session id must match:** the DLL the test process loads must carry the collector's session id. If the coverage file is tiny or empty, check `work\raw\\collector.log` and confirm the app-local copy (`MUXControlsTestApp\Microsoft.UI.Xaml.Controls.dll`) is the instrumented one. +- **Rebuilds overwrite the instrumented copy:** rebuilding `MUXControlsTestApp` re-stages an older Controls DLL into `BuildOutput\...\UnpackagedApps\MUXControlsTestApp`. That doesn't matter here because Step 2 overwrites the *payload* copies, but don't run `run-tests-on-vm.ps1` **without** `-SkipPayload` in between, because that recreates the payload. +- **Expected failures still count toward coverage:** failing or ignored tests still execute product code until they fail, and Ignored tests don't run at all. +- **Known crash at exit is harmless:** TitleBar runs fail-fast at app exit, which shows as cleanup errors. Coverage still flushes because the collector is out of process. +- **Numbers differ from CI:** a local VM run uses a different OS build, flavor (x64 vs CI's x86) and test subset. Compare per file, not the module total. +- **NULL DACL:** the collector pipe is open to every local user until shutdown, so that low-integrity test processes can connect (otherwise coverage is empty with ACCESS_DENIED). That's fine on a dedicated test VM, but avoid it on shared machines; the agent workflow tells the user before every run. +- **`cmd /c` quoting:** starting the collector via `cmd /c "" ... > ""` silently fails, because cmd strips the outer quotes. Step 3 therefore writes a `.cmd` wrapper. If the collector pipe never appears, check that wrapper and `C:\WinUICoverage\out\collector-.log` on the VM. +- **Same tool for instrument and collect:** the collector must be the same `Microsoft.CodeCoverage.Console.exe` version that instrumented the DLL. Step 2 records the tool, and Step 3 stages that exact version to `C:\WinUICoverage\CoverageTool\`. If that tool is gone or changed version (for example after a VS update), Step 2 re-instruments instead of reusing the cache. When several VS installs exist, the highest file version wins; path string order is not used. +- **Relink is not byte-deterministic:** Step 1 therefore reuses its previous output while the recorded link inputs are unchanged. Otherwise the Step 2 cache, keyed by DLL hash, would miss and re-instrument for 45 minutes. +- **Single-file bug in `lib\Get-FunctionCoverage.ps1`:** with a single `.coverage` input, the script's own merge produces an empty ``. An if-expression unwraps the one-element array, so `@inputFiles` splats nothing. Step 5 works around this by pre-merging (`dotnet-coverage merge -f xml`) and calling the script with `-SkipMerge`, so the script itself is left as is. +- **Host suspends pause the run:** a Cloud PC host that sleeps or suspends pauses instrumentation; it resumes and finishes afterwards. Keep the host awake for predictable timing. + +## Parameters (`Invoke-WinUIVmCoverage.ps1`) + +| Parameter | Default | Notes | +|---|---|---| +| `-RepoRoot` | required | WinUI checkout with a completed local build | +| `-VMName` | required | Hyper-V VM | +| `-TestFilter` | required | One or more filters passed to `run-tests-on-vm.ps1`, e.g. `*.TitleBarTests.*`. With several, Step 3 runs once per filter and Step 5 merges the `.coverage` files. | +| `-OutputDir` | `\BuildOutput\Coverage\\` | Work and report root (git-ignored by default) | +| `-ComponentName` / `-ComponentPathRegex` | TitleBar | Regex on the source path selecting the component's files | +| `-Module` | `Microsoft.UI.Xaml.Controls.dll` | or `Microsoft.UI.Xaml.Controls.Tabular.dll` (TableView and the tabular helpers), or `Microsoft.ui.xaml.dll` | +| `-Flavor` | `amd64chk` | Build flavor; payload `x64chk` | +| `-BaselinePerFileCsv` / `-BaselineLabel` / `-BaselineRepoRoot` | none | A `corrected-per-file.csv` from another coverage run to compare with, e.g. `PerModule\\corrected-per-file.csv` from an earlier run of this skill, or `lib\Split-CoverageByModule.ps1` run over a CI build's merged Cobertura file | +| `-MoveSessionToConsole` | off | Recommended on Cloud PC / RDP hosts | +| `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first; stops if it fails | +| `-ForceReinstrument` | off | Ignore the instrumented-DLL cache | +| `-SkipTestCheck` | off | Skip listing the matching tests in the payload (same test files and selection as `runtests`) before instrumenting | +| `-SkipFunctionCoverage` | off | Skip the function-level merge | + +## Files in this skill + +| File | Purpose | +|---|---| +| `scripts\Invoke-WinUIVmCoverage.ps1` | One-command orchestrator for Steps 1-5. Step 3 runs once per `-TestFilter`. The payload and VM are restored in `finally` (and at the start of the next run if a run was killed). | +| `scripts\Step1-New-InstrumentableDll.ps1` | Relinks MUXC or Tabular with `/DEBUGTYPE:CV,FIXUP` from the recorded link command, outside BuildOutput. | +| `scripts\Step2-Instrument-Payload.ps1` | Static instrumentation (cached) and broadcast into `TestPayload\`, with backups. | +| `scripts\Step3-Invoke-VmCoverageRun.ps1` | Starts the collector on the VM, runs the tests through `tools\run-tests-on-vm.ps1 -SkipPayload`, shuts the collector down and downloads the coverage file. | +| `scripts\Step4-Restore-Payload.ps1` | Restores the payload and the VM copies, hash-verified. | +| `scripts\Step5-Get-ComponentCoverage.ps1` | Merge, per-module dedup, component filter, baseline comparison, function coverage, HTML tree and `coverage-summary.md`. | +| `scripts\lib\Set-CoveragePipeAcl.ps1` | Puts a NULL DACL on the collector's named pipe so low-integrity TAEF test processes can connect. | +| `scripts\lib\Repair-CoverageDuplicates.ps1`, `Split-CoverageByModule.ps1` | Deduplicate merged Cobertura by (file, line), overall and per module, so the numbers match the Azure DevOps Code Coverage tab. | +| `scripts\lib\Get-FunctionCoverage.ps1` | Per-function coverage from the native XML merge. | +| `scripts\lib\Build-CoverageTreeReport.ps1`, `Build-FunctionCoverageTreeReport.ps1` | Single-file HTML folder/file (and function) tree reports. | + +Shared repo tool used by Gate 1: `tools\Set-VmCredential.ps1` validates the VM, takes the password in a separate window, writes the encrypted credential cache (only after every check passes), and checks the logon and the active desktop. diff --git a/.github/skills/code-coverage-calculation/scripts/Invoke-WinUIVmCoverage.ps1 b/.github/skills/code-coverage-calculation/scripts/Invoke-WinUIVmCoverage.ps1 new file mode 100644 index 000000000..fe9a4bae5 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/Invoke-WinUIVmCoverage.ps1 @@ -0,0 +1,181 @@ +<# +.SYNOPSIS + One command: code coverage of a WinUI component from ONLY the tests you choose, run on a local + Hyper-V VM. + +.DESCRIPTION + relink (instrumentable copy) -> instrument + broadcast into TestPayload -> collector + tests on VM + -> download .coverage -> restore payload/VM -> component report (optionally vs. a baseline). + * The payload and the VM are restored in a finally block even if the run fails. If a previous + run was killed before it could restore, this script restores that first. + * A restore failure fails the command (after the report is written, if coverage was collected). + * Only the generated TestPayload\ folder and the VM's deployed payload are modified; + BuildOutput and repo sources are never touched. + +.EXAMPLE + # TitleBar coverage from the TitleBar tests only (output: BuildOutput\Coverage\amd64chk\TitleBar) + .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM ` + -TestFilter '*.TitleBarTests.*' -ComponentName TitleBar ` + -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` + -MoveSessionToConsole + +.EXAMPLE + # TableView lives in Microsoft.UI.Xaml.Controls.Tabular.dll + .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM ` + -TestFilter '*.TableViewSortingTests.*' -ComponentName TableView -Module Microsoft.UI.Xaml.Controls.Tabular.dll ` + -ComponentPathRegex '\\controls\\dev\\TableView\\' +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$VMName, + # One or more runtests filters; each runs with its own collector session, then all are merged. + [Parameter(Mandatory)][string[]]$TestFilter, + # Work + report folder. Default: \BuildOutput\Coverage\\ (git-ignored). + [string]$OutputDir = '', + [string]$ComponentName = 'TitleBar', + [string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar', + [ValidateSet('Microsoft.UI.Xaml.Controls.dll', 'Microsoft.UI.Xaml.Controls.Tabular.dll', 'Microsoft.ui.xaml.dll')] + [string]$Module = 'Microsoft.UI.Xaml.Controls.dll', + [string]$Flavor = 'amd64chk', + [string]$BaselinePerFileCsv = '', + [string]$BaselineLabel = 'Baseline', + # Root of the checkout that produced -BaselinePerFileCsv, if it is not a build-agent root. + [string]$BaselineRepoRoot = '', + [PSCredential]$Credential, + [switch]$MoveSessionToConsole, + [switch]$RefreshPayload, + [switch]$ForceReinstrument, + # Skip the pre-check that lists the matching tests in the payload before instrumenting. + [switch]$SkipTestCheck, + [switch]$SkipFunctionCoverage +) +$ErrorActionPreference = 'Stop' +$scripts = $PSScriptRoot +$RepoRoot = (Resolve-Path $RepoRoot).Path +if (-not $OutputDir) { $OutputDir = Join-Path $RepoRoot "BuildOutput\Coverage\$Flavor\$ComponentName" } +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path +$work = Join-Path $OutputDir 'work' +$plat = if ($Flavor -match '^(amd64|x86|arm64)') { @{ amd64 = 'x64'; x86 = 'x86'; arm64 = 'arm64' }[$Matches[1]] } else { 'x64' } +$cfg = if ($Flavor -match 'fre$') { 'fre' } else { 'chk' } +$payloadFlavor = "$plat$cfg" +$payloadDir = Join-Path $RepoRoot "TestPayload\$payloadFlavor" +# Native link project of each module that needs the Step 1 relink (MUX already links with CV/FIXUP). +$relinkProject = @{ 'Microsoft.UI.Xaml.Controls.dll' = 'controls\dev\dll'; 'Microsoft.UI.Xaml.Controls.Tabular.dll' = 'controls\dev\dll-tabular' } +$runLabel = Get-Date -Format 'yyyyMMdd-HHmmss' +$transcript = Join-Path $OutputDir "coverage-run-$runLabel.log" +Start-Transcript $transcript | Out-Null +Write-Host "== Output: $OutputDir" +# Outer try/finally: the transcript is stopped whatever happens. +try { + $sw = [Diagnostics.Stopwatch]::StartNew() + + # A previous run that was killed before Step 4 leaves instrument-state.json behind: restore first. + if (Test-Path (Join-Path $work 'instrument-state.json')) { + Write-Host "== A previous run did not finish restoring the payload; restoring it first" + & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential + } + + $runs = @() + $runError = $null + $restoreError = $null + try { + if ($RefreshPayload) { + Write-Host "== Refreshing TestPayload\$payloadFlavor from BuildOutput" + Push-Location $RepoRoot + try { + & .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet + $code = $LASTEXITCODE + } finally { Pop-Location } + if ($code -ne 0) { + throw "CreateTestPayload failed (exit code $code); see $payloadDir\CreateTestPayload.log. Fix the build or payload, then retry." + } + } + $missing = @('runtests.cmd', 'te.exe' | Where-Object { -not (Test-Path (Join-Path $payloadDir $_)) }) + if (-not (Test-Path $payloadDir) -or $missing) { + throw "Test payload $payloadDir is missing or incomplete ($($missing -join ', ')). Build the repo, then rerun with -RefreshPayload." + } + if (-not (Get-ChildItem $payloadDir -Recurse -Filter $Module -File | Select-Object -First 1)) { + throw "No copy of $Module in $payloadDir. Build the repo, then rerun with -RefreshPayload." + } + + # Fail fast, before the long instrumentation, if the payload that will be deployed has no + # tests matching a filter (for example because the build predates the component's tests). + # Mirrors the test files and Desktop selection that runtests.ps1 uses on the VM. + if (-not $SkipTestCheck) { + Push-Location $payloadDir + # TAEF needs /select:"" passed verbatim; PowerShell 7's default quoting turns the + # query into a file name (TAEF then lists every test). + $savedArgPassing = $PSNativeCommandArgumentPassing + $PSNativeCommandArgumentPassing = 'Legacy' + try { + $testFiles = @(Get-ChildItem Test -Filter 'Microsoft.UI.Xaml.Tests.*.dll' -EA SilentlyContinue | ForEach-Object { "Test\$($_.Name)" }) + + @('Test\MUXControls.Test.dll', 'Test\UnpackagedApps\MUXControlsTestApp\MUXControlsTestApp.dll', 'Test\IXMPTestApp.appx' | Where-Object { Test-Path $_ }) + foreach ($f in $TestFilter) { + $select = "@Name='*$f' and (@TestPass:IncludeOnlyOn='*Desktop*' OR (NOT(@TestPass:IncludeOnlyOn=*) AND NOT(@TestPass:ExcludeOn='*Desktop*')))" + $nameRx = '^' + ([regex]::Escape("*$f") -replace '\\\*', '.*') + '$' + $listing = @(& .\te.exe @testFiles /list "/select:`"$select`"" 2>&1 | ForEach-Object { "$_".Trim() }) + if ($LASTEXITCODE -ne 0) { + throw "Listing tests with te.exe failed (exit code $LASTEXITCODE) for '$f':`n$(($listing | Where-Object { $_ } | Select-Object -Last 10) -join "`n")" + } + $count = @($listing | Where-Object { $_ -match $nameRx }).Count + Write-Host "== Test check: '$f' matches $count test(s) in TestPayload\$payloadFlavor" + if (-not $count) { + throw "No tests in TestPayload\$payloadFlavor match '$f'. Check the filter, and that the build is current and contains the component's tests (rebuild, then use -RefreshPayload). Use -SkipTestCheck to bypass this check." + } + } + } + finally { $PSNativeCommandArgumentPassing = $savedArgPassing; Pop-Location } + } + + Write-Host "== Step 1: instrumentable $Module" + if ($relinkProject.ContainsKey($Module)) { + $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor ` + -ObjSubDir $relinkProject[$Module] -ProjectSubDir $relinkProject[$Module] -ModuleName $Module + $srcDll = $rl.dll + } else { + # MUX already links with /debugtype:cv,fixup,pdata; stage its DLL+PDB side by side. + $d = Join-Path $work 'mux'; New-Item -ItemType Directory -Force $d | Out-Null + Copy-Item (Join-Path $RepoRoot "BuildOutput\bin\$Flavor\Product\Microsoft.ui.xaml.dll") $d -Force + Copy-Item (Get-ChildItem (Join-Path $RepoRoot "BuildOutput\bin\$Flavor") -Recurse -Filter Microsoft.ui.xaml.pdb | Select-Object -First 1).FullName $d -Force + $srcDll = Join-Path $d 'Microsoft.ui.xaml.dll' + } + + Write-Host "== Step 2: instrument + broadcast into payload" + $st = & (Join-Path $scripts 'Step2-Instrument-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -SourceDll $srcDll -PayloadFlavor $payloadFlavor -ForceReinstrument:$ForceReinstrument + + Write-Host "== Step 3: collector + tests on $VMName" + for ($i = 0; $i -lt $TestFilter.Count; $i++) { + $label = if ($TestFilter.Count -gt 1) { "$runLabel-$($i + 1)" } else { $runLabel } + Write-Host " filter $($i + 1)/$($TestFilter.Count): $($TestFilter[$i])" + $runs += & (Join-Path $scripts 'Step3-Invoke-VmCoverageRun.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -TestFilter $TestFilter[$i] ` + -Flavor $Flavor -Credential $Credential -RunLabel $label -MoveSessionToConsole:$MoveSessionToConsole + } + } + catch { $runError = $_; throw } + finally { + Write-Host "== Step 4: restore payload + VM" + try { & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential } + catch { + $restoreError = $_ + Write-Warning "RESTORE FAILED: $_`n instrument-state.json was kept. Rerun this script (it restores first) or run:`n $scripts\Step4-Restore-Payload.ps1 -RepoRoot '$RepoRoot' -WorkDir '$work' -VMName $VMName" + # The original failure (if any) is rethrown after this block; only note the restore error here. + if ($runError) { Write-Warning "The run itself had already failed: $runError" } + } + } + + Write-Host "== Step 5: $ComponentName report" + $rep = & (Join-Path $scripts 'Step5-Get-ComponentCoverage.ps1') -CoverageFiles @($runs.coverageFile) -OutputDir (Join-Path $OutputDir "report-$runLabel") -RepoRoot $RepoRoot ` + -Module ([IO.Path]::GetFileNameWithoutExtension($Module)) -ComponentPathRegex $ComponentPathRegex -ComponentName $ComponentName ` + -BaselinePerFileCsv $BaselinePerFileCsv -BaselineLabel $BaselineLabel -BaselineRepoRoot $BaselineRepoRoot ` + -RunLabel "tests '$($TestFilter -join "', '")'" -SkipFunctionCoverage:$SkipFunctionCoverage + $testSummary = ($runs | ForEach-Object testSummary) -join ' | ' + Write-Host ("== DONE in {0:hh\:mm\:ss}: {1} {2}/{3} lines = {4} % | tests: {5}" -f $sw.Elapsed, $ComponentName, $rep.coveredLines, $rep.totalLines, $rep.lineCoveragePct, $testSummary) + Write-Host " summary: $($rep.summary)" + if ($restoreError) { + throw "Coverage report written ($($rep.summary)), but restoring the payload/VM FAILED: $restoreError. The payload may still be instrumented; rerun this script (it restores first) or run Step4-Restore-Payload.ps1 -WorkDir '$work'." + } + $rep | Add-Member testSummary $testSummary -PassThru | Add-Member coverageFile @($runs.coverageFile) -PassThru +} +finally { Stop-Transcript | Out-Null } diff --git a/.github/skills/code-coverage-calculation/scripts/Step1-New-InstrumentableDll.ps1 b/.github/skills/code-coverage-calculation/scripts/Step1-New-InstrumentableDll.ps1 new file mode 100644 index 000000000..30af27d87 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/Step1-New-InstrumentableDll.ps1 @@ -0,0 +1,120 @@ +<# +.SYNOPSIS + Step 1 - Produce an instrumentable copy of a WinUI chk product DLL (default: + Microsoft.UI.Xaml.Controls.dll) WITHOUT touching the repo or BuildOutput. + +.DESCRIPTION + Static native instrumentation (Microsoft.CodeCoverage.Console instrument) needs the CV + FIXUP + debug streams. MUXC's Debug (chk) link does not emit them, so instrumenting the normal chk DLL + prints "Unable to obtain debug information. Link with the /PROFILE linker switch." and exits 0 + with the image untouched. Emitting them needs /DEBUGTYPE:CV,FIXUP with /INCREMENTAL:NO at link + time. + + This skill must not edit repo files, so instead of changing the build this script re-runs the + EXACT link command MSBuild last recorded (link.command.1.tlog) with: + * /DEBUGTYPE:CV,FIXUP added (and /INCREMENTAL:NO enforced), + * /OUT, /PDB, /IMPLIB, /MAP redirected into -OutputDir, + * /WINMD disabled (the .winmd is not needed for coverage). + The repo's BuildOutput DLL/PDB are never overwritten. Object files are reused as-is, so the code + is byte-for-byte the same build - only the debug info differs. + + Microsoft.ui.xaml.dll (MUX) already links with /debugtype:cv,fixup,pdata, so for MUX you can skip + this step and instrument BuildOutput\bin\\Product\Microsoft.ui.xaml.dll directly. + +.EXAMPLE + .\Step1-New-InstrumentableDll.ps1 -RepoRoot C:\src\microsoft-ui-xaml -OutputDir C:\cov\work\relink +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$OutputDir, + [string]$Flavor = 'amd64chk', + # Project-relative obj dir that holds the link .tlog for the module. + [string]$ObjSubDir = 'controls\dev\dll', + # Directory the original link ran in (relative paths such as /DEF are resolved from here). + [string]$ProjectSubDir = 'controls\dev\dll', + [string]$ModuleName = 'Microsoft.UI.Xaml.Controls.dll' +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[relink] $m" } + +$objDir = Join-Path $RepoRoot "BuildOutput\obj\$Flavor\$ObjSubDir" +$projDir = Join-Path $RepoRoot $ProjectSubDir +if (-not (Test-Path $objDir)) { throw "obj dir not found: $objDir (build the repo first)" } + +$stem = [IO.Path]::GetFileNameWithoutExtension($ModuleName) +$tlog = Get-ChildItem $objDir -Recurse -Filter 'link.command.1.tlog' | + Where-Object { ([IO.File]::ReadAllText($_.FullName)) -match [regex]::Escape("\$stem.dll".ToUpperInvariant()) } | + Select-Object -First 1 +if (-not $tlog) { throw "No link.command.1.tlog under $objDir mentions $ModuleName" } +Info "link tlog : $($tlog.FullName)" + +# tlog layout: line 0 = '^' (input list), remaining lines = the command line (options + objs). +$lines = [IO.File]::ReadAllText($tlog.FullName) -split "`r?`n" +$cmd = ($lines | Select-Object -Skip 1 | Where-Object { $_ -and -not $_.StartsWith('^') }) -join "`n" +if (-not $cmd) { throw "Could not read link command from $($tlog.FullName)" } + +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path +$outDll = Join-Path $OutputDir $ModuleName +$outPdb = Join-Path $OutputDir "$stem.pdb" +$outLib = Join-Path $OutputDir "$stem.lib" +$outMap = Join-Path $OutputDir "$stem.map" + +# The link is not byte-deterministic, and Step 2 caches the (slow) instrumentation by the relinked +# DLL's hash, so reuse the previous relink when the recorded link command/inputs are unchanged. +$resultFile = Join-Path $OutputDir 'relink-result.json' +$inputsStamp = '{0}|{1}' -f (Get-FileHash $tlog.FullName).Hash, + ((Get-ChildItem (Split-Path $tlog.FullName) -Filter 'link.read.*.tlog' | Get-FileHash | ForEach-Object Hash) -join ',') +$newestObj = Get-ChildItem $objDir -Filter *.obj | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 +if ((Test-Path $resultFile) -and (Test-Path $outDll) -and (Test-Path $outPdb)) { + $prev = Get-Content $resultFile -Raw | ConvertFrom-Json + if ($prev.inputsStamp -eq $inputsStamp -and (-not $newestObj -or $newestObj.LastWriteTimeUtc -lt (Get-Item $outDll).LastWriteTimeUtc)) { + Info "reusing previous relink (link inputs unchanged): $outDll" + return [pscustomobject]@{ dll = $outDll; pdb = $outPdb } + } +} + +$q = '(?:"[^"]*"|\S+)' +$cmd = [regex]::Replace($cmd, "(?i)/OUT:$q", "/OUT:`"$outDll`"") +$cmd = [regex]::Replace($cmd, "(?i)/PDB:$q", "/PDB:`"$outPdb`"") +$cmd = [regex]::Replace($cmd, "(?i)/IMPLIB:$q", "/IMPLIB:`"$outLib`"") +$cmd = [regex]::Replace($cmd, "(?i)/WINMDFILE:$q", '') +$cmd = [regex]::Replace($cmd, "(?i)/WINMD(?=\s)", '/WINMD:NO') +$cmd = [regex]::Replace($cmd, "(?i)/MAP(?=\s)", "/MAP:`"$outMap`"") +$cmd = [regex]::Replace($cmd, "(?i)/INCREMENTAL(:\w+)?", '/INCREMENTAL:NO') +$cmd = [regex]::Replace($cmd, "(?i)/DEBUGTYPE:$q", '') +$cmd += "`n/DEBUGTYPE:CV,FIXUP" + +$rsp = Join-Path $OutputDir 'relink.rsp' +[IO.File]::WriteAllText($rsp, $cmd, [Text.Encoding]::Unicode) +Info "response : $rsp" + +# Resolve link.exe from the MSVC toolset recorded in the command's /LIBPATH (same toolset that built the objs). +$link = $null +$m = [regex]::Match($cmd, '(?i)"([^"]*\\VC\\TOOLS\\MSVC\\[\d\.]+)\\LIB\\X64"') +if ($m.Success) { + $cand = Join-Path $m.Groups[1].Value 'bin\Hostx64\x64\link.exe' + if (Test-Path $cand) { $link = $cand } +} +if (-not $link) { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products * -property installationPath + $link = Get-ChildItem "$vs\VC\Tools\MSVC" -Recurse -Filter link.exe | Where-Object FullName -match 'Hostx64\\x64' | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $link) { throw 'link.exe not found' } +Info "linker : $link" + +$sw = [Diagnostics.Stopwatch]::StartNew() +Push-Location $projDir +try { + $out = & $link "@$rsp" 2>&1 + $exit = $LASTEXITCODE +} finally { Pop-Location } +$out | Where-Object { $_ -match 'error|warning LNK' } | Select-Object -First 20 | ForEach-Object { Info " $_" } +if ($exit -ne 0 -or -not (Test-Path $outDll)) { throw "relink failed (exit $exit)" } +Info ("relinked in {0:mm\:ss}: {1} ({2:N0} bytes)" -f $sw.Elapsed, $outDll, (Get-Item $outDll).Length) + +[pscustomobject]@{ dll = $outDll; pdb = $outPdb; linkTlog = $tlog.FullName; linker = $link; rsp = $rsp; inputsStamp = $inputsStamp } | + ConvertTo-Json | Set-Content $resultFile -Encoding UTF8 +[pscustomobject]@{ dll = $outDll; pdb = $outPdb } diff --git a/.github/skills/code-coverage-calculation/scripts/Step2-Instrument-Payload.ps1 b/.github/skills/code-coverage-calculation/scripts/Step2-Instrument-Payload.ps1 new file mode 100644 index 000000000..a1cf5be14 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/Step2-Instrument-Payload.ps1 @@ -0,0 +1,171 @@ +<# +.SYNOPSIS + Step 2 - Statically instrument the module and broadcast it into the local test payload. + +.DESCRIPTION + Static native instrumentation of one product DLL for a local payload that is deployed to a + Hyper-V VM: + + * Instruments ONE copy (VS Microsoft.CodeCoverage.Console.exe, static native instrumentation, + a fresh 16-hex session id) - this is slow for a 38 MB chk DLL (~20-50 min), so the result is + CACHED in \instrumented keyed by the SHA256 of the input DLL and reused next time + (together with the session id that is baked into it). The cache is only reused while the + coverage tool that instrumented it still exists at the same file version, because the + collector (Step 3) must be that same tool. + * Detects "fails while exiting 0" (no /PROFILE-style debug info) by checking the tool output + and that the image grew, and requires static_covrun*.dll to be produced. + * Backs up every payload copy of the module to \payload-backup, then copies the + instrumented DLL + static_covrun*.dll over every copy (a test app loads its own co-located + copy, e.g. Test\UnpackagedApps\MUXControlsTestApp\). The backup is refreshed from the payload + whenever that copy is not instrumented, so a rebuilt payload is never restored to an older + build's DLL; an existing backup is kept only while the copy is still instrumented. + * Writes \instrument-state.json (session id, copies, backups, runtimes added) used by + the run and restore steps. It is written after every backup is taken but BEFORE the first + payload file is overwritten ("complete": false until all copies are done), so an interrupted + run can always be restored by Step4-Restore-Payload.ps1 (the orchestrator does that + automatically at the start of the next run). + + Never edits repo source or BuildOutput. Only TestPayload\ (a generated, git-ignored + folder) is modified, and Step4-Restore-Payload.ps1 restores it byte-for-byte. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$WorkDir, + # The instrumentable DLL (Step 1 output) and its PDB next to it. + [Parameter(Mandatory)][string]$SourceDll, + [string]$PayloadFlavor = 'x64chk', + [string]$CoverageConsole = '', + [switch]$ForceReinstrument +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[instrument] $m" } + +if (-not $CoverageConsole) { + # Highest file version wins (string-sorting install paths would rank "2022" above "18"). + $CoverageConsole = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio" -Recurse -Filter Microsoft.CodeCoverage.Console.exe -Depth 8 -EA SilentlyContinue | + Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $CoverageConsole -or -not (Test-Path $CoverageConsole)) { throw 'Microsoft.CodeCoverage.Console.exe not found (install VS Enterprise code coverage).' } +$toolVersion = (Get-Item $CoverageConsole).VersionInfo.FileVersion +Info "tool : $CoverageConsole ($toolVersion)" + +$moduleName = Split-Path $SourceDll -Leaf +$stem = [IO.Path]::GetFileNameWithoutExtension($moduleName) +$pdb = Join-Path (Split-Path $SourceDll) "$stem.pdb" +if (-not (Test-Path $pdb)) { throw "PDB must sit next to the DLL: $pdb" } + +New-Item -ItemType Directory -Force $WorkDir | Out-Null +$WorkDir = (Resolve-Path $WorkDir).Path +$instrDir = Join-Path $WorkDir 'instrumented' +$cacheFile = Join-Path $instrDir 'instrumented-cache.json' +$srcHash = (Get-FileHash $SourceDll).Hash + +# ---- 1. instrument (or reuse cache) --------------------------------------------------------- +$cache = if (Test-Path $cacheFile) { Get-Content $cacheFile -Raw | ConvertFrom-Json } else { $null } +$instrDll = Join-Path $instrDir $moduleName +$cacheHit = -not $ForceReinstrument -and $cache -and $cache.sourceHash -eq $srcHash -and (Test-Path $instrDll) -and + (Get-FileHash $instrDll).Hash -eq $cache.instrumentedHash +# The collector must come from the same tool (and version) that instrumented the binary. +$cacheToolOk = $cache -and $cache.coverageTool -and (Test-Path $cache.coverageTool) -and + "$((Get-Item $cache.coverageTool).VersionInfo.FileVersion)" -eq "$($cache.toolVersion)" +if ($cacheHit -and $cacheToolOk) { + $sessionId = $cache.sessionId + $CoverageConsole = $cache.coverageTool; $toolVersion = $cache.toolVersion + Info "reusing cached instrumented DLL (session $sessionId, tool $toolVersion)" +} else { + if ($cacheHit) { Info "cached DLL was instrumented by '$($cache.coverageTool)' $($cache.toolVersion), which is no longer available at that version; re-instrumenting" } + Remove-Item $instrDir -Recurse -Force -EA SilentlyContinue + New-Item -ItemType Directory -Force $instrDir | Out-Null + Copy-Item $SourceDll, $pdb $instrDir + $sessionId = [guid]::NewGuid().ToString('N').Substring(0, 16) + $cfg = Join-Path $instrDir 'instrument.config' + @' + + + + True + False + .* + + +'@ | Set-Content $cfg -Encoding UTF8 + $before = (Get-Item $instrDll).Length + Info "instrumenting $moduleName with session $sessionId (slow for large chk DLLs)..." + $sw = [Diagnostics.Stopwatch]::StartNew() + $out = & $CoverageConsole instrument $instrDll --session-id $sessionId --settings $cfg --nologo 2>&1 + $exit = $LASTEXITCODE + $text = ($out | ForEach-Object { "$_" }) -join "`n" + $text | Set-Content (Join-Path $instrDir 'instrument.log') + $after = (Get-Item $instrDll).Length + Info ("instrument exit={0} in {1:mm\:ss}; size {2:N0} -> {3:N0}" -f $exit, $sw.Elapsed, $before, $after) + if ($exit -ne 0 -or $text -match 'Unable to obtain debug information|/PROFILE linker switch' -or + -not ($text -match 'successfully instrumented' -or $after -gt $before)) { + throw "Instrumentation failed or silently skipped (see $instrDir\instrument.log). If it mentions /PROFILE, run Step1 (relink with /DEBUGTYPE:CV,FIXUP) first." + } + if (-not (Get-ChildItem $instrDir -Filter 'static_covrun*.dll')) { throw "static_covrun*.dll was not produced next to $instrDll" } + [pscustomobject]@{ sessionId = $sessionId; sourceDll = $SourceDll; sourceHash = $srcHash + instrumentedHash = (Get-FileHash $instrDll).Hash; coverageTool = $CoverageConsole; toolVersion = $toolVersion + createdAt = (Get-Date).ToString('o') } | + ConvertTo-Json | Set-Content $cacheFile -Encoding UTF8 +} +$runtimes = @(Get-ChildItem $instrDir -Filter 'static_covrun*.dll') + +# ---- 2. broadcast into the payload (back up originals) -------------------------------------- +$payload = Join-Path $RepoRoot "TestPayload\$PayloadFlavor" +if (-not (Test-Path (Join-Path $payload 'runtests.cmd'))) { throw "Test payload not found at $payload. Run tools\run-tests-on-vm.ps1 (or test\CreateTestPayload.ps1) once first." } +$backupRoot = Join-Path $WorkDir 'payload-backup' +$statePath = Join-Path $WorkDir 'instrument-state.json' +$state = if (Test-Path $statePath) { Get-Content $statePath -Raw | ConvertFrom-Json } else { $null } +if ($state -and $state.payloadDir -ne $payload) { throw "instrument-state.json belongs to another payload ($($state.payloadDir)); restore it first." } + +$copies = @(Get-ChildItem $payload -Recurse -Filter $moduleName -File) +if (-not $copies) { throw "No copies of $moduleName under $payload" } +$instrHash = (Get-FileHash $instrDll).Hash +$knownInstrumented = @($instrHash, $(if ($state) { $state.instrumentedHash })) | Where-Object { $_ } + +# Pass 1 (no destructive writes): back up every copy and work out which runtimes will be added. +$entries = @() +$runtimesAdded = @() +foreach ($c in $copies) { + $rel = $c.FullName.Substring($payload.Length + 1) + $bak = Join-Path $backupRoot $rel + # A copy that is not instrumented is the payload's current original (possibly from a newer build + # than an earlier backup), so always back it up. Keep an existing backup only while the copy is + # still instrumented (e.g. an earlier run was not restored yet). + $isInstrumented = ($knownInstrumented -contains (Get-FileHash $c.FullName).Hash) -or + [bool](Get-ChildItem $c.DirectoryName -Filter 'static_covrun*.dll' -File -EA SilentlyContinue) + if (-not $isInstrumented) { + New-Item -ItemType Directory -Force (Split-Path $bak) | Out-Null + Copy-Item $c.FullName $bak -Force + } elseif (-not (Test-Path $bak)) { + throw "$rel is already instrumented but has no backup; recreate the payload (-RefreshPayload)." + } + foreach ($rt in $runtimes) { + $rtDest = Join-Path $c.DirectoryName $rt.Name + if (-not (Test-Path $rtDest)) { $runtimesAdded += $rtDest.Substring($payload.Length + 1) } + } + $entries += [pscustomobject]@{ relativePath = $rel; backup = $bak; originalHash = (Get-FileHash $bak).Hash } +} +if ($state -and $state.runtimesAdded) { $runtimesAdded = @($state.runtimesAdded) + $runtimesAdded | Select-Object -Unique } + +# Persist the recovery state BEFORE the first overwrite, so Step 4 can always restore, even if +# a copy below fails or the process is killed part-way ("complete" stays false until the end). +$newState = [pscustomobject]@{ + module = $moduleName; moduleStem = $stem; sessionId = $sessionId; payloadDir = $payload + instrumentedDll = $instrDll; instrumentedHash = $instrHash; coverageTool = $CoverageConsole; toolVersion = $toolVersion + copies = $entries; runtimesAdded = @($runtimesAdded); complete = $false; instrumentedAt = (Get-Date).ToString('o') +} +$newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8 + +# Pass 2: overwrite every copy and add the coverage runtimes next to it. +foreach ($e in $entries) { + $dest = Join-Path $payload $e.relativePath + Copy-Item $instrDll $dest -Force + foreach ($rt in $runtimes) { Copy-Item $rt.FullName (Join-Path (Split-Path $dest) $rt.Name) -Force } + Info " instrumented copy -> $($e.relativePath)" +} +$newState.complete = $true +$newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8 +Info "payload instrumented: $($entries.Count) copies, session $sessionId (state: $statePath)" +$newState diff --git a/.github/skills/code-coverage-calculation/scripts/Step3-Invoke-VmCoverageRun.ps1 b/.github/skills/code-coverage-calculation/scripts/Step3-Invoke-VmCoverageRun.ps1 new file mode 100644 index 000000000..bff212b36 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/Step3-Invoke-VmCoverageRun.ps1 @@ -0,0 +1,219 @@ +<# +.SYNOPSIS + Step 3 - Run the selected tests on a Hyper-V VM with a code-coverage collector attached and + download the resulting .coverage file. + +.DESCRIPTION + How the collector is wired up: + 1. Stage the VS code-coverage tools (CodeCoverage.Console + Dynamic Code Coverage Tools + + InstrumentationEngine) on the VM once (C:\WinUICoverage\CoverageTool), no VS/dotnet needed. + 2. Start "Microsoft.CodeCoverage.Console.exe collect --session-id --server-mode --output " + as the logged-in VM user (interactive scheduled task - it must outlive the PS Direct session). + 3. Wait for \\.\pipe\CodeCoverage.pipe., then give it a NULL DACL + (lib\Set-CoveragePipeAcl.ps1) so low-integrity test processes can connect. + 4. Run the tests with the repo's own tools\run-tests-on-vm.ps1 -SkipPayload. -SkipPayload only + skips regenerating TestPayload from BuildOutput (which would overwrite the instrumented + DLLs); the runner still copies the changed payload files to the VM and runs runtests.cmd + on the interactive desktop. + 5. "Microsoft.CodeCoverage.Console.exe shutdown " -> collector finalizes the .coverage. + 6. Copy the .coverage + collector logs back to \raw (also when the run fails), and + always close the PowerShell Direct session. + + Precondition: the VM user's desktop must be interactive. If the host is a Cloud PC / RDP session + that can disconnect, use -MoveSessionToConsole so input does not depend on vmconnect's enhanced + session (runs "tscon /dest:console" as SYSTEM inside the VM). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$WorkDir, + [Parameter(Mandatory)][string]$VMName, + [Parameter(Mandatory)][string]$TestFilter, + [string]$Flavor = 'amd64chk', + [PSCredential]$Credential, + [string]$RunLabel = '', + [switch]$MoveSessionToConsole, + [int]$PipeTimeoutSec = 90, + [int]$FlushTimeoutSec = 600 +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[vm-run] $m" } + +$WorkDir = (Resolve-Path $WorkDir).Path +$state = Get-Content (Join-Path $WorkDir 'instrument-state.json') -Raw | ConvertFrom-Json +$sid = $state.sessionId +if (-not $RunLabel) { $RunLabel = Get-Date -Format 'yyyyMMdd-HHmmss' } +$rawDir = Join-Path $WorkDir "raw\$RunLabel" +New-Item -ItemType Directory -Force $rawDir | Out-Null + +# Credential: same per-user DPAPI cache tools\run-tests-on-vm.ps1 uses. +if (-not $Credential) { + $credFile = Join-Path $env:USERPROFILE ".winui-test\vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml" + if (-not (Test-Path $credFile)) { throw "No cached VM credential ($credFile). Run tools\run-tests-on-vm.ps1 once, or pass -Credential." } + $Credential = Import-Clixml $credFile +} +$user = $Credential.UserName +$session = New-PSSession -VMName $VMName -Credential $Credential + +$vmRoot = 'C:\WinUICoverage' +# Collector must be the same tool version that instrumented the binary (recorded by Step 2). +$hostTool = $state.coverageTool +if (-not $hostTool -or -not (Test-Path $hostTool)) { + $hostTool = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio" -Recurse -Filter Microsoft.CodeCoverage.Console.exe -Depth 8 -EA SilentlyContinue | + Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 -ExpandProperty FullName +} +$toolVer = (Get-Item $hostTool).VersionInfo.FileVersion +$vmToolDir = "$vmRoot\CoverageTool\$toolVer" +$vmTool = "$vmToolDir\Microsoft.CodeCoverage.Console.exe" +$vmOut = "$vmRoot\out\coverage-$RunLabel-$($state.moduleStem).coverage" +$vmLog = "$vmRoot\out\collector-$RunLabel.log" + +try { + # ---- interactive desktop check ----------------------------------------------------------- + $desk = Invoke-Command -Session $session -ScriptBlock { + param($u) + $rows = (qwinsta 2>$null) | Select-Object -Skip 1 + $mine = $rows | Where-Object { $_ -match "\s$u\s" } | Select-Object -First 1 + [pscustomobject]@{ line = "$mine"; onConsole = ("$mine" -match '^\s*>?console\s'); active = ("$mine" -match '\sActive') ; + id = if ("$mine" -match "\s$u\s+(\d+)\s") { [int]$Matches[1] } else { -1 } } + } -ArgumentList $user + Info "VM session: $($desk.line.Trim())" + if (-not $desk.active) { throw "User $user has no Active session on $VMName. Log on to the VM desktop first." } + if (-not $desk.onConsole) { + if ($MoveSessionToConsole) { + Info "moving session $($desk.id) to the VM console (tscon as SYSTEM)" + Invoke-Command -Session $session -ScriptBlock { + param($id) + $a = New-ScheduledTaskAction -Execute "$env:windir\System32\tscon.exe" -Argument "$id /dest:console" + $p = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + Register-ScheduledTask -TaskName 'WinUICov-tscon' -Action $a -Principal $p -Force | Out-Null + Start-ScheduledTask 'WinUICov-tscon'; Start-Sleep 5 + Unregister-ScheduledTask 'WinUICov-tscon' -Confirm:$false + } -ArgumentList $desk.id + } else { + Write-Warning "User session is not on the VM console. If the host's vmconnect/RDP session disconnects, UI input fails ('Access is denied'). Re-run with -MoveSessionToConsole to avoid that." + } + } + + # ---- 1. stage collector tools (once) ------------------------------------------------------ + $hasTool = Invoke-Command -Session $session -ScriptBlock { param($t) Test-Path $t } -ArgumentList $vmTool + if (-not $hasTool) { + $cc = $hostTool + $vsBase = $cc.Substring(0, $cc.IndexOf('\Common7\')) + $stage = Join-Path $WorkDir "CoverageTool\$toolVer" + Remove-Item $stage -Recurse -Force -EA SilentlyContinue + New-Item -ItemType Directory -Force $stage, "$stage\amd64", "$stage\x64", "$stage\x86" | Out-Null + Copy-Item "$vsBase\Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\*" $stage -Recurse -Force + $van = "$vsBase\Team Tools\Dynamic Code Coverage Tools" + if (Test-Path $van) { Copy-Item "$van\*" $stage -Recurse -Force; if (Test-Path "$van\amd64") { Copy-Item "$van\amd64\*" "$stage\amd64" -Recurse -Force } } + $ie = "$vsBase\Common7\IDE\CommonExtensions\Platform\InstrumentationEngine" + foreach ($a in 'x64', 'x86') { if (Test-Path "$ie\$a") { Copy-Item "$ie\$a\*" "$stage\$a" -Recurse -Force } } + $zip = Join-Path $WorkDir "CoverageTool-$toolVer.zip" + Remove-Item $zip -EA SilentlyContinue + Compress-Archive -Path "$stage\*" -DestinationPath $zip + Invoke-Command -Session $session -ScriptBlock { param($r, $d) New-Item -ItemType Directory -Force $d, "$r\out" | Out-Null } -ArgumentList $vmRoot, $vmToolDir + Copy-Item $zip "$vmRoot\CoverageTool.zip" -ToSession $session -Force + Invoke-Command -Session $session -ScriptBlock { param($r, $d) Expand-Archive "$r\CoverageTool.zip" $d -Force; Remove-Item "$r\CoverageTool.zip" } -ArgumentList $vmRoot, $vmToolDir + Info "staged collector tools ($toolVer) on VM: $vmTool" + } + Invoke-Command -Session $session -ScriptBlock { param($r) New-Item -ItemType Directory -Force "$r\out" | Out-Null } -ArgumentList $vmRoot + Copy-Item (Join-Path $PSScriptRoot 'lib\Set-CoveragePipeAcl.ps1') "$vmRoot\Set-CoveragePipeAcl.ps1" -ToSession $session -Force + + # ---- 2. start the collector as the interactive user --------------------------------------- + Invoke-Command -Session $session -ScriptBlock { + param($tool, $sid, $out, $log, $u, $r) + if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { throw "A collector for session $sid is already running on the VM. Run with a fresh session or shut it down: `"$tool`" shutdown $sid" } + Remove-Item $out, $log -EA SilentlyContinue + # Use a .cmd wrapper: "cmd /c" strips the outer quotes of a command line that starts and + # ends with a quote, which silently breaks a quoted exe path + quoted redirections. + $wrapper = "$r\collector-wrapper.cmd" + "@echo off`r`n`"$tool`" collect --session-id $sid --server-mode --output `"$out`" > `"$log`" 2>&1" | Set-Content $wrapper -Encoding ASCII + $a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c `"$wrapper`"" + $p = New-ScheduledTaskPrincipal -UserId $u -LogonType Interactive -RunLevel Highest + $s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) + Register-ScheduledTask -TaskName 'WinUICov-Collector' -Action $a -Principal $p -Settings $s -Force | Out-Null + Start-ScheduledTask 'WinUICov-Collector' + } -ArgumentList $vmTool, $sid, $vmOut, $vmLog, $user, $vmRoot + Info "collector started (session $sid) -> $vmOut" + + # ---- 3. wait for the pipe, open its DACL --------------------------------------------------- + $ready = Invoke-Command -Session $session -ScriptBlock { + param($sid, $timeout, $log) + $deadline = (Get-Date).AddSeconds($timeout) + while ((Get-Date) -lt $deadline) { + if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { return $true } + if ((Get-ScheduledTask 'WinUICov-Collector').State -ne 'Running') { break } + Start-Sleep -Milliseconds 500 + } + Get-Content $log -EA SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Host " collector: $_" } + return $false + } -ArgumentList $sid, $PipeTimeoutSec, $vmLog + if (-not $ready) { throw "Collector pipe for session $sid did not appear on the VM." } + Invoke-Command -Session $session -ScriptBlock { + param($r, $sid) & powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$r\Set-CoveragePipeAcl.ps1" -SessionId $sid + } -ArgumentList $vmRoot, $sid | ForEach-Object { Info " $_" } + + # ---- 4. run the tests with the repo's VM runner (payload already instrumented) ------------- + # run-tests-on-vm.ps1 deploys incrementally using a size+mtime manifest of what it copied last + # time. Step 4 restores the VM without updating that manifest, and static_covrun*.dll keep the + # tool's own timestamp, so a later run would skip them: the VM keeps the original DLL (empty + # coverage) or gets the instrumented DLL without its runtime (the app fails to activate MUXC + # types). Drop those entries so the instrumented files are always deployed. + $instrumentedRel = @($state.copies.relativePath) + @($state.runtimesAdded) + $manifestPattern = ".deploy-manifest-$($VMName -replace '[^a-zA-Z0-9]', '_')-*.json" + foreach ($mf in Get-ChildItem (Join-Path $RepoRoot 'TestPayload') -Filter $manifestPattern -File -EA SilentlyContinue) { + $entries = Get-Content $mf.FullName -Raw | ConvertFrom-Json + $keep = [ordered]@{} + foreach ($p in $entries.PSObject.Properties) { + if ($instrumentedRel -notcontains $p.Name -and $p.Name -notmatch '(^|\\)static_covrun\w*\.dll$') { $keep[$p.Name] = $p.Value } + } + ($keep | ConvertTo-Json -Depth 1) | Set-Content $mf.FullName -Encoding UTF8 + } + $testLog = Join-Path $rawDir 'vm-test.log' + Info "running tests: run-tests-on-vm.ps1 -SkipPayload $TestFilter (log: $testLog)" + Push-Location $RepoRoot + try { + & (Join-Path $RepoRoot 'initrun.ps1') -Flavor $Flavor .\tools\run-tests-on-vm.ps1 -VMName $VMName -SkipPayload $TestFilter *>&1 | + Tee-Object -FilePath $testLog | Where-Object { "$_" -match 'Summary:|EndGroup:|Tests (PASSED|FAILED)|Error|\[\d/3\]' } | ForEach-Object { Write-Host " $_" } + } finally { Pop-Location } + $summary = Select-String $testLog -Pattern 'Summary:\s+Total=' | Select-Object -Last 1 + if (-not $summary) { + if (Select-String $testLog -Pattern 'selection criteria did not match any tests' -Quiet) { + throw "No tests matched '$TestFilter' in the deployed payload. Check the filter, and that the build (and TestPayload) is current and contains the component's tests." + } + throw "The test run produced no TAEF summary; see $testLog." + } + Info "test summary: $($summary.Line.Trim())" +} +finally { + # ---- 5. always flush: shut the collector down, wait for the file -------------------------- + try { + $flush = Invoke-Command -Session $session -ScriptBlock { + param($tool, $sid, $out, $timeout) + $o = if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { & $tool shutdown $sid 2>&1 | Out-String } else { 'no collector pipe (collector not running)' } + $deadline = (Get-Date).AddSeconds($timeout) + do { Start-Sleep 3; $t = Get-ScheduledTask 'WinUICov-Collector' -EA SilentlyContinue } while ($t -and $t.State -eq 'Running' -and (Get-Date) -lt $deadline) + if ($t -and $t.State -eq 'Running') { Stop-ScheduledTask 'WinUICov-Collector' } + Unregister-ScheduledTask 'WinUICov-Collector' -Confirm:$false -EA SilentlyContinue + [pscustomobject]@{ shutdown = $o.Trim(); size = if (Test-Path $out) { (Get-Item $out).Length } else { 0 } } + } -ArgumentList $vmTool, $sid, $vmOut, $FlushTimeoutSec + Info ("collector shutdown: {0}; coverage size {1:N0} bytes" -f $flush.shutdown, $flush.size) + } catch { Write-Warning "collector shutdown failed: $_" } + + # ---- 6. download (also on failure, so the collector log is kept) and always close the session - + # Copy errors are not raised here (they would hide the original failure); a missing or empty + # coverage file is reported below. + $local = Join-Path $rawDir (Split-Path $vmOut -Leaf) + try { + Copy-Item $vmOut $local -FromSession $session -Force -EA SilentlyContinue + Copy-Item $vmLog (Join-Path $rawDir 'collector.log') -FromSession $session -Force -EA SilentlyContinue + } finally { Remove-PSSession $session -EA SilentlyContinue } +} + +if (-not (Test-Path $local) -or (Get-Item $local).Length -lt 1024) { + throw "Coverage file missing or empty ($local). Check $rawDir\collector.log and that the test process loaded the instrumented DLL (session $sid)." +} +Info ("downloaded {0} ({1:N1} MB)" -f $local, ((Get-Item $local).Length / 1MB)) +[pscustomobject]@{ coverageFile = $local; testLog = $testLog; testSummary = "$($summary.Line)".Trim(); sessionId = $sid; rawDir = $rawDir } | + Tee-Object -Variable result | ConvertTo-Json | Set-Content (Join-Path $rawDir 'run-result.json') -Encoding UTF8 +$result diff --git a/.github/skills/code-coverage-calculation/scripts/Step4-Restore-Payload.ps1 b/.github/skills/code-coverage-calculation/scripts/Step4-Restore-Payload.ps1 new file mode 100644 index 000000000..e0308135f --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/Step4-Restore-Payload.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Step 4 - Restore the original (uninstrumented) module in the local payload and on the VM. + +.DESCRIPTION + Uses \instrument-state.json written by Step 2: + * copies every backed-up original back over its payload copy (hash-verified; copies that + already match their original, e.g. because Step 2 was interrupted before overwriting them, + are left alone), + * deletes the static_covrun*.dll files that Step 2 added, + * optionally (-VMName) does the same inside the VM's deployed payload + (C:\TestPayload\-, the path tools\run-tests-on-vm.ps1 uses) and + throws if any VM copy does not match its original afterwards, + * removes instrument-state.json only after everything was restored (on failure it is kept, + so this script can simply be run again; backups are kept for audit). + The deploy manifest of tools\run-tests-on-vm.ps1 is size+mtime based, so the next normal run + re-deploys the restored files anyway; the VM step just makes the VM clean immediately. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$WorkDir, + [string]$VMName, + [PSCredential]$Credential +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[restore] $m" } + +$statePath = Join-Path $WorkDir 'instrument-state.json' +if (-not (Test-Path $statePath)) { Info 'nothing to restore (no instrument-state.json)'; return } +$state = Get-Content $statePath -Raw | ConvertFrom-Json +$payload = $state.payloadDir + +foreach ($c in $state.copies) { + $dest = Join-Path $payload $c.relativePath + # Tolerate copies that were never overwritten (e.g. Step 2 was interrupted part-way). + if ((Test-Path $dest) -and (Get-FileHash $dest).Hash -eq $c.originalHash) { Info "unchanged $($c.relativePath)"; continue } + Copy-Item $c.backup $dest -Force + if ((Get-FileHash $dest).Hash -ne $c.originalHash) { throw "restore hash mismatch: $dest" } + Info "restored $($c.relativePath)" +} +foreach ($r in $state.runtimesAdded) { + $p = Join-Path $payload $r + if (Test-Path $p) { Remove-Item $p -Force; Info "removed $r" } +} + +if ($VMName) { + if (-not $Credential) { + $Credential = Import-Clixml (Join-Path $env:USERPROFILE ".winui-test\vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml") + } + $remoteDir = "C:\TestPayload\$env:COMPUTERNAME-$(Split-Path $RepoRoot -Leaf)" + $s = New-PSSession -VMName $VMName -Credential $Credential + try { + $vmErrors = @() + Invoke-Command -Session $s -ScriptBlock { New-Item -ItemType Directory -Force C:\WinUICoverage | Out-Null } + # Copies can come from different originals (e.g. an app folder with an older staged DLL), + # so restore each distinct original to exactly the copies it was backed up from. + foreach ($g in ($state.copies | Group-Object originalHash)) { + $tmp = "C:\WinUICoverage\restore-$($g.Name.Substring(0,12))-$($state.module)" + Copy-Item $g.Group[0].backup $tmp -ToSession $s -Force + Invoke-Command -Session $s -ScriptBlock { + param($dir, $tmp, [string[]]$rels, $hash) + if (-not (Test-Path $dir)) { return "remote payload $dir not found; skipped" } + foreach ($r in $rels) { $d = Join-Path $dir $r; if (Test-Path (Split-Path $d)) { Copy-Item $tmp $d -Force } } + Remove-Item $tmp -Force + $bad = @($rels | Where-Object { (Test-Path (Join-Path $dir $_)) -and (Get-FileHash (Join-Path $dir $_)).Hash -ne $hash }) + if ($bad) { "WARNING: hash mismatch on VM for $($bad -join ', ')" } else { "VM restored $($rels.Count) copy(ies) of original $($hash.Substring(0,8))" } + } -ArgumentList $remoteDir, $tmp, @($g.Group.relativePath), $g.Name | ForEach-Object { + Info $_ + if ("$_" -like 'WARNING:*') { $vmErrors += "$_" } + } + } + Invoke-Command -Session $s -ScriptBlock { + param($dir, [string[]]$rts) foreach ($r in $rts) { Remove-Item (Join-Path $dir $r) -Force -EA SilentlyContinue } + } -ArgumentList $remoteDir, @($state.runtimesAdded) + if ($vmErrors) { + throw "VM restore incomplete ($($vmErrors -join '; ')). instrument-state.json was kept; run Step4-Restore-Payload.ps1 again." + } + } finally { Remove-PSSession $s } +} +Remove-Item $statePath -Force +Info 'done' diff --git a/.github/skills/code-coverage-calculation/scripts/Step5-Get-ComponentCoverage.ps1 b/.github/skills/code-coverage-calculation/scripts/Step5-Get-ComponentCoverage.ps1 new file mode 100644 index 000000000..938cb4510 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/Step5-Get-ComponentCoverage.ps1 @@ -0,0 +1,181 @@ +<# +.SYNOPSIS + Step 5 - Turn the downloaded .coverage file(s) into a component-scoped coverage report. + +.DESCRIPTION + Uses the scripts in .\lib: + * dotnet-coverage merge -f cobertura -> merged.cobertura.xml + * lib\Split-CoverageByModule.ps1 -> per-module, (file,line)-deduplicated numbers + (same correction that makes numbers match ADO) + * lib\Build-CoverageTreeReport.ps1 -> single-file HTML tree for the component + * lib\Get-FunctionCoverage.ps1 -> per-function coverage (filtered to the component) + and adds: + * component filtering (-ComponentPathRegex, matched against the source path), + * an optional before/after comparison against a baseline corrected-per-file.csv (e.g. from a + CI coverage build), matched on the repo-relative path so checkout roots, build-agent + prefixes (C:\__w\1\s\...) and flavors (x86chk/amd64chk) do not matter, + * coverage-summary.md + component-per-file.csv + component-functions.csv. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string[]]$CoverageFiles, + [Parameter(Mandatory)][string]$OutputDir, + [string]$Module = 'Microsoft.UI.Xaml.Controls', + # Regex on the source file path selecting the component's files. + [string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar', + [string]$ComponentName = 'TitleBar', + [string]$BaselinePerFileCsv = '', + [string]$BaselineLabel = 'Baseline', + [string]$RunLabel = 'This run', + # Root of the checkout that produced the coverage; used to make its paths repo-relative. + [string]$RepoRoot = '', + # Root of the checkout (or build agent) that produced -BaselinePerFileCsv, when it is not a + # standard build-agent root such as C:\__w\1\s\ (e.g. D:\src\other-checkout). + [string]$BaselineRepoRoot = '', + [switch]$SkipFunctionCoverage +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[report] $m" } +$lib = Join-Path $PSScriptRoot 'lib' + +$dc = (Get-Command dotnet-coverage -EA SilentlyContinue).Source +if (-not $dc) { throw 'dotnet-coverage not found: dotnet tool install --global dotnet-coverage' } +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path + +# ---- merge -> cobertura ------------------------------------------------------------------------- +$cob = Join-Path $OutputDir 'merged.cobertura.xml' +Info "merging $($CoverageFiles.Count) file(s) -> $cob" +& $dc merge @CoverageFiles -f cobertura -o $cob --nologo 2>&1 | Select-Object -Last 3 | ForEach-Object { Info " $_" } +if (-not (Test-Path $cob)) { throw 'merge produced no cobertura file' } + +# ---- per-module dedup (verified to match ADO's coverage tab) ------------------------------------- +$perModule = Join-Path $OutputDir 'PerModule' +& (Join-Path $lib 'Split-CoverageByModule.ps1') -InputCobertura $cob -OutputDir $perModule -Modules $Module | Out-Null +$modDir = Get-ChildItem $perModule -Directory | Where-Object { $_.Name -ieq $Module } | Select-Object -First 1 +if (-not $modDir) { throw "Module $Module not found in coverage (modules: $((Get-ChildItem $perModule -Directory).Name -join ', '))" } +$modCsv = Join-Path $modDir.FullName 'corrected-per-file.csv' +$modSummary = Get-Content (Join-Path $modDir.FullName 'corrected-summary.json') -Raw | ConvertFrom-Json + +function Get-RootKey([string]$r) { if ($r) { ($r.ToLowerInvariant() -replace '/', '\').TrimEnd('\') + '\' } else { '' } } +$rootKeys = @((Get-RootKey $RepoRoot), (Get-RootKey $BaselineRepoRoot)) | Where-Object { $_ } +function Get-RepoKey([string]$p) { + # repo-relative key: strip a known checkout root (this run's -RepoRoot or -BaselineRepoRoot), + # else a build-agent root (e.g. a CI baseline: C:\__w\1\s\), else everything up to the first + # distinctive top-level repo folder (controls, dxaml, BuildOutput) and only then the generic + # ones (src, test); then normalize build flavor dirs. + $k = $p.ToLowerInvariant() -replace '/', '\' + $root = $rootKeys | Where-Object { $k.StartsWith($_) } | Select-Object -First 1 + if ($root) { $k = $k.Substring($root.Length) } + elseif ($k -match '^[a-z]:\\+(__w|a)\\+\d+\\+s\\+') { $k = $k.Substring($Matches[0].Length) } + elseif ($k -match '\\(controls|dxaml|buildoutput)\\') { $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput)\\)', '' } + else { $k = $k -replace '^.*?\\(?=(src|test)\\)', '' } + $k -replace '\\obj\\(x86|amd64|arm64)(chk|fre)\\', '\obj\\' +} + +$rows = Import-Csv $modCsv | Where-Object { $_.File -match $ComponentPathRegex } +if (-not $rows) { throw "No files matched -ComponentPathRegex '$ComponentPathRegex' in $Module coverage." } +$base = @{} +if ($BaselinePerFileCsv) { + Import-Csv $BaselinePerFileCsv | Where-Object { $_.File -match $ComponentPathRegex } | ForEach-Object { $base[(Get-RepoKey $_.File)] = $_ } +} +$comp = foreach ($r in $rows) { + $k = Get-RepoKey $r.File + $b = $base[$k] + [pscustomobject]@{ + File = $k + CoveredLines = [int]$r.CoveredLines; TotalLines = [int]$r.TotalLines + LineCoverage = if ([int]$r.TotalLines) { [math]::Round(100.0 * $r.CoveredLines / $r.TotalLines, 2) } else { 0 } + BaselineCovered = if ($b) { [int]$b.CoveredLines } else { $null } + BaselineTotal = if ($b) { [int]$b.TotalLines } else { $null } + BaselineCoverage = if ($b -and [int]$b.TotalLines) { [math]::Round(100.0 * $b.CoveredLines / $b.TotalLines, 2) } else { $null } + SourcePath = $r.File + } +} +$comp = $comp | Sort-Object File +$baselineWarning = '' +if ($BaselinePerFileCsv) { + $matched = @($comp | Where-Object { $null -ne $_.BaselineTotal }).Count + if ($matched -eq 0) { + $baselineWarning = "The baseline matched none of the $(@($comp).Count) component files, so every baseline value is n/a. If the baseline came from a different checkout, pass its root with -BaselineRepoRoot." + } elseif ($matched -lt @($comp).Count / 2) { + $baselineWarning = "The baseline matched only $matched of $(@($comp).Count) component files. If it came from a different checkout, pass its root with -BaselineRepoRoot." + } + if ($baselineWarning) { Write-Warning $baselineWarning } +} +$comp | Export-Csv (Join-Path $OutputDir 'component-per-file.csv') -NoTypeInformation +$tot = ($comp | Measure-Object TotalLines -Sum).Sum +$cov = ($comp | Measure-Object CoveredLines -Sum).Sum +$pct = if ($tot) { [math]::Round(100.0 * $cov / $tot, 2) } else { 0 } + +# HTML tree for the component (lib\Build-CoverageTreeReport.ps1; it needs File,CoveredLines,TotalLines) +$treeCsv = Join-Path $OutputDir 'component-tree-input.csv' +$comp | Select-Object @{n='File';e={$_.SourcePath}}, CoveredLines, TotalLines, LineCoverage | Export-Csv $treeCsv -NoTypeInformation +& (Join-Path $lib 'Build-CoverageTreeReport.ps1') -InputCsv $treeCsv -OutputDir (Join-Path $OutputDir 'TreeReport') ` + -Title "$ComponentName coverage ($RunLabel)" -ModuleNames "$Module.dll" | Out-Null + +# ---- function coverage (optional) ----------------------------------------------------------------- +$funcRows = @() +if (-not $SkipFunctionCoverage) { + $fcDir = Join-Path $OutputDir 'FunctionCoverage' + try { + # Pre-merge here and call the reused script with -SkipMerge: its own merge passes the inputs + # via "@inputFiles" after an if-expression, which unwraps a SINGLE file to a scalar string and + # splats nothing (empty ). The pipeline always had 20 slices, so it never hit this. + $native = Join-Path $fcDir 'NativeMerge\merged-native.xml' + New-Item -ItemType Directory -Force (Split-Path $native) | Out-Null + & $dc merge @CoverageFiles -f xml -o $native --nologo 2>&1 | Select-Object -Last 1 | ForEach-Object { Info " $_" } + & (Join-Path $lib 'Get-FunctionCoverage.ps1') -CoverageFiles $CoverageFiles -OutputDir $fcDir -ExistingPerFileCsv $modCsv -SkipMerge | Out-Null + $fcCsv = Join-Path $fcDir 'corrected-per-function.csv' + if (Test-Path $fcCsv) { + $funcRows = Import-Csv $fcCsv | Where-Object { $_.File -match $ComponentPathRegex } | + Sort-Object File, @{e={[double]$_.LineCoveragePct}} + $funcRows | Export-Csv (Join-Path $OutputDir 'component-functions.csv') -NoTypeInformation + } + } catch { Write-Warning "function coverage failed: $_" } +} + +# ---- summary markdown ----------------------------------------------------------------------------- +$md = New-Object Text.StringBuilder +[void]$md.AppendLine("# $ComponentName code coverage - $RunLabel") +[void]$md.AppendLine() +[void]$md.AppendLine("Module: ``$Module.dll`` | Coverage file(s): $($CoverageFiles.Count) | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm')") +[void]$md.AppendLine() +[void]$md.AppendLine("**$ComponentName total: $cov / $tot lines = $pct %** (whole module in this run: $($modSummary.CoveredLines) / $($modSummary.TotalLines) = $($modSummary.LineCoverage) %)") +if ($BaselinePerFileCsv) { + $bt = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineTotal -Sum).Sum + $bc = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineCovered -Sum).Sum + if ($bt) { [void]$md.AppendLine("`n$BaselineLabel for the same files: $bc / $bt = $([math]::Round(100.0*$bc/$bt,2)) %") } + if ($baselineWarning) { [void]$md.AppendLine("`n> **Warning:** $baselineWarning") } +} +[void]$md.AppendLine() +[void]$md.AppendLine("| File | Covered / Total | $RunLabel % | $BaselineLabel % |") +[void]$md.AppendLine('|---|---|---|---|') +foreach ($c in $comp) { + $bp = if ($null -ne $c.BaselineCoverage) { "$($c.BaselineCoverage) % ($($c.BaselineCovered)/$($c.BaselineTotal))" } else { 'n/a' } + [void]$md.AppendLine("| ``$($c.File)`` | $($c.CoveredLines) / $($c.TotalLines) | **$($c.LineCoverage) %** | $bp |") +} +if ($funcRows) { + # Hand-written .cpp files of the component (works for controls\dev and dxaml components alike). + $hand = $funcRows | Where-Object { $_.File -match '\.cpp$' -and $_.File -notmatch '\\generated\\|\\winrtgeneratedclasses\\|\.g\.cpp$' } + $unc = @($hand | Where-Object { [int]$_.CoveredLines -eq 0 }) + $part = @($hand | Where-Object { [int]$_.CoveredLines -gt 0 -and [int]$_.CoveredLines -lt [int]$_.TotalLines }) + [void]$md.AppendLine() + [void]$md.AppendLine("## Functions in $ComponentName .cpp files") + [void]$md.AppendLine("$(@($hand).Count) functions: $(@($hand).Count - $unc.Count - $part.Count) fully covered, $($part.Count) partially, $($unc.Count) not covered.") + if ($unc) { + [void]$md.AppendLine("`n**Not covered:**") + $unc | ForEach-Object { [void]$md.AppendLine("- ``$($_.Function)`` ($(Split-Path $_.File -Leaf), $($_.TotalLines) lines)") } + } + if ($part) { + [void]$md.AppendLine("`n**Partially covered (lowest first):**") + $part | Select-Object -First 25 | ForEach-Object { [void]$md.AppendLine("- ``$($_.Function)`` $($_.CoveredLines)/$($_.TotalLines) ($($_.LineCoveragePct) %)") } + } +} +[void]$md.AppendLine() +[void]$md.AppendLine('Files: `component-per-file.csv`, `component-functions.csv`, `TreeReport\coverage-tree.html`, `PerModule\`, `merged.cobertura.xml`.') +$md.ToString() | Set-Content (Join-Path $OutputDir 'coverage-summary.md') -Encoding UTF8 +Info "$ComponentName : $cov / $tot = $pct % -> $(Join-Path $OutputDir 'coverage-summary.md')" +[pscustomobject]@{ component = $ComponentName; coveredLines = $cov; totalLines = $tot; lineCoveragePct = $pct + summary = (Join-Path $OutputDir 'coverage-summary.md'); perFile = (Join-Path $OutputDir 'component-per-file.csv') + baselineWarning = $baselineWarning } diff --git a/.github/skills/code-coverage-calculation/scripts/lib/Build-CoverageTreeReport.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Build-CoverageTreeReport.ps1 new file mode 100644 index 000000000..a193f8643 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/lib/Build-CoverageTreeReport.ps1 @@ -0,0 +1,455 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Build-CoverageTreeReport.ps1 +# +# Renders a SINGLE, self-contained HTML file with a collapsible folder-tree view of code +# coverage - as opposed to reportgenerator's default `Html` report type, which emits one .html +# page PER SOURCE FILE/CLASS (thousands of files for a full WinUI build). This script performs +# no new coverage computation of its own - it is a pure rendering step on top of data already +# produced (and already verified to match Azure DevOps's own Code Coverage tab exactly) by +# Repair-CoverageDuplicates.ps1: `corrected-per-file.csv` and `corrected-summary.json`. +# +# OUTPUT: exactly one .html file. No external .css/.js/.svg assets, no per-file pages, no CDN +# dependencies - everything (styles, script, and the coverage data itself) is embedded inline, +# so the file can be opened directly from disk (or emailed, attached to a work item, etc.) with +# no network access and no companion folder. +# +# TREE UI: folders are collapsed by default at every level (only top-level root folders are +# visible on first load); clicking a folder row expands/collapses just that folder's immediate +# children (subfolders and files) one level at a time. Each folder shows a coverage percentage +# that is the SUM of covered/total lines across every file nested anywhere beneath it (computed +# bottom-up); each file shows its own exact percentage. Both use the same 3-tier ADO-style +# color scale: green >= 75%, yellow 40-75%, red < 40%. + +[CmdletBinding()] +param( + # Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1. + [Parameter(Mandatory = $true)] + [string]$InputCsv, + + # Path to corrected-summary.json (for the overall KPI header). Optional - if omitted or + # missing, the KPI header is computed from the per-file rows instead. + [string]$InputSummaryJson = "", + + # Folder to write the single HTML report into. Created if it doesn't exist. Kept separate + # from reportgenerator's own (multi-file) `html\` output folder by convention. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Report title shown at the top of the page. + [string]$Title = "Code Coverage Report", + + # Regex matched against the START of each file path and stripped before display (the + # "boilerplate" build-agent prefix). Default matches the Azure Pipelines agent convention + # (e.g. C:\__w\1\s\, D:\a\3\s\ etc. - a drive letter, "__w" or "a", a numeric leg id, then + # "s\") case-insensitively, on either slash direction. If a given path doesn't match, it is + # left completely unchanged - this is a display-only trim, never a hard requirement. + [string]$StripPathPrefix = '^[A-Za-z]:[\\/]+(__w|a)[\\/]+\d+[\\/]+s[\\/]+', + + # Output file name. + [string]$OutputFileName = 'coverage-tree.html', + + # Name(s) of the instrumented product DLL(s) this coverage measures (e.g. + # "Microsoft.ui.xaml.dll"). Shown at the top of the report next to the title. If not + # supplied, falls back to the "InstrumentedModules" field in -InputSummaryJson (as written + # by Repair-CoverageDuplicates.ps1's -ModuleNames parameter); if that's also unavailable, + # the report simply omits the module line rather than guessing. + [string[]]$ModuleNames = @() +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $InputCsv)) { + throw "Input CSV not found: $InputCsv" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "Building single-file folder-tree coverage report from $InputCsv ..." +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +$rows = Import-Csv $InputCsv +if ($rows.Count -eq 0) { + throw "No rows found in $InputCsv - nothing to render." +} + +$stripRegex = $null +if ($StripPathPrefix) { + try { $stripRegex = New-Object System.Text.RegularExpressions.Regex($StripPathPrefix, 'IgnoreCase') } + catch { Write-Host "WARNING: -StripPathPrefix is not a valid regex ('$StripPathPrefix'); paths will be shown unmodified. $($_.Exception.Message)" -ForegroundColor Yellow } +} + +# --------------------------------------------------------------------------------------------- +# Build an in-memory folder tree. Each node: @{ Name; IsFile; Covered; Total; Children (ordered +# dictionary, folders/files keyed by name) }. Folder Covered/Total are computed bottom-up after +# every file has been inserted. +# --------------------------------------------------------------------------------------------- +function New-TreeNode { + param([string]$Name, [bool]$IsFile) + [pscustomobject]@{ + Name = $Name + IsFile = $IsFile + Covered = 0 + Total = 0 + Children = [ordered]@{} + } +} + +$root = New-TreeNode -Name '' -IsFile $false +$strippedCount = 0 +$totalCount = 0 + +foreach ($row in $rows) { + $totalCount++ + $path = $row.File + if ($stripRegex -and $stripRegex.IsMatch($path)) { + $path = $stripRegex.Replace($path, '', 1) + $strippedCount++ + } + $path = $path -replace '/', '\' + $segments = $path.Split('\') | Where-Object { $_ -ne '' } + if ($segments.Count -eq 0) { continue } + + $covered = [int]$row.CoveredLines + $total = [int]$row.TotalLines + + $node = $root + for ($i = 0; $i -lt $segments.Count; $i++) { + $seg = $segments[$i] + $isLeaf = ($i -eq $segments.Count - 1) + if (-not $node.Children.Contains($seg)) { + $node.Children[$seg] = New-TreeNode -Name $seg -IsFile $isLeaf + } + $node = $node.Children[$seg] + } + # $node is now the leaf (file) node. + $node.Covered = $covered + $node.Total = $total +} + +Write-Host ("Stripped the boilerplate prefix from {0}/{1} paths." -f $strippedCount, $totalCount) + +# Bottom-up rollup: a folder's Covered/Total = sum of its children's (already-rolled-up) values. +function Get-Rollup { + param($Node) + if ($Node.IsFile) { return } + $c = 0; $t = 0 + foreach ($child in $Node.Children.Values) { + Get-Rollup -Node $child + $c += $child.Covered + $t += $child.Total + } + $Node.Covered = $c + $Node.Total = $t +} +Get-Rollup -Node $root + +# --------------------------------------------------------------------------------------------- +# Convert to a compact, JSON-serializable shape: folders sorted before files, then alphabetical. +# Keys are kept short (n/t/c/f/k) since this blob is embedded directly in the HTML and a full +# WinUI build can have several thousand files. +# --------------------------------------------------------------------------------------------- +function ConvertTo-JsonNode { + param($Node) + $pct = if ($Node.Total -gt 0) { [math]::Round(100.0 * $Node.Covered / $Node.Total, 2) } else { 0 } + $obj = [ordered]@{ + n = $Node.Name + c = $Node.Covered + t = $Node.Total + p = $pct + } + if ($Node.IsFile) { + $obj['f'] = 1 + } else { + $sortedChildren = $Node.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } + $obj['k'] = @($sortedChildren | ForEach-Object { ConvertTo-JsonNode -Node $_ }) + } + return $obj +} + +$treeData = @($root.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } | ForEach-Object { ConvertTo-JsonNode -Node $_ }) +$treeJson = $treeData | ConvertTo-Json -Depth 100 -Compress +if ($treeData.Count -eq 1) { + # ConvertTo-Json unwraps single-element arrays unless -AsArray is available (PS 6.1+); force array shape. + $treeJson = "[$treeJson]" +} + +# --------------------------------------------------------------------------------------------- +# Overall KPI header - prefer the already-computed summary (exact figures verified against ADO); +# fall back to summing the per-file rows if the summary file wasn't supplied/found. +# --------------------------------------------------------------------------------------------- +$summary = $null +if ($InputSummaryJson -and (Test-Path $InputSummaryJson)) { + $summary = Get-Content $InputSummaryJson -Raw | ConvertFrom-Json +} +if (-not $summary) { + $totalLines = ($rows | Measure-Object -Property TotalLines -Sum).Sum + $coveredLines = ($rows | Measure-Object -Property CoveredLines -Sum).Sum + $summary = [pscustomobject]@{ + Modules = $rows.Count + TotalLines = $totalLines + CoveredLines = $coveredLines + LineCoverage = if ($totalLines -gt 0) { [math]::Round(100.0 * $coveredLines / $totalLines, 2) } else { 0 } + } +} + +# Resolve which DLL(s) this report is measuring: an explicit -ModuleNames wins; otherwise fall +# back to the "InstrumentedModules" field already recorded in corrected-summary.json (when the +# producer recorded one); if neither is available the +# report simply omits the module line rather than guessing at a DLL name. +$resolvedModuleNames = @($ModuleNames) +if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') { + $resolvedModuleNames = @($summary.InstrumentedModules) +} +$moduleDisplay = if ($resolvedModuleNames -and $resolvedModuleNames.Count -gt 0) { ($resolvedModuleNames -join ', ') } else { '' } + +# Make sure SUMMARY (embedded in the HTML for the KPI cards) reflects the resolved module list +# regardless of which path it came from (explicit -ModuleNames vs. corrected-summary.json). +if ($summary.PSObject.Properties.Name -contains 'InstrumentedModules') { + $summary.InstrumentedModules = @($resolvedModuleNames) +} else { + $summary | Add-Member -NotePropertyName 'InstrumentedModules' -NotePropertyValue @($resolvedModuleNames) +} + +$summaryJson = $summary | ConvertTo-Json -Compress + +# --------------------------------------------------------------------------------------------- +# HTML template. Single-quoted here-string (no PowerShell variable expansion) with placeholder +# tokens swapped in afterward - this keeps every '$' in the embedded JavaScript (template +# literals, etc.) completely literal and avoids any PowerShell-escaping gymnastics. +# --------------------------------------------------------------------------------------------- +$htmlTemplate = @' + + + + +__TITLE__ + + + +

__TITLE__

+__MODULE_LINE__ +
Generated __GENERATED_AT__ · boilerplate path prefix stripped from every entry below
+ +
+ +
+ ≥ 75% + 40–75% + < 40% + Click a folder row to expand/collapse it +
+ + + +
+ +
Self-contained report - no external files. Coverage data is bug-fixed for a verified duplicate-line defect in the native (dotnet-coverage / Microsoft.CodeCoverage.Console) merge engine; these numbers match the Azure DevOps Code Coverage tab.
+ + + + +'@ + +$outFile = Join-Path $OutputDir $OutputFileName +$moduleLineHtml = if ($moduleDisplay) { + '
Code coverage measured for: ' + [System.Net.WebUtility]::HtmlEncode($moduleDisplay) + '
' +} else { + '' +} +$html = $htmlTemplate.Replace('__TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)) +$html = $html.Replace('__MODULE_LINE__', $moduleLineHtml) +$html = $html.Replace('__GENERATED_AT__', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) +$html = $html.Replace('__TREE_JSON__', $treeJson) +$html = $html.Replace('__SUMMARY_JSON__', $summaryJson) + +Set-Content -Path $outFile -Value $html -Encoding UTF8 + +Write-Host ("`nWrote single-file tree report in {0:N1}s -> {1}" -f $sw.Elapsed.TotalSeconds, $outFile) +if ($moduleDisplay) { Write-Host (" Module(s): {0}" -f $moduleDisplay) } +Write-Host (" Overall: {0}% ({1} / {2} lines across {3} files)" -f $summary.LineCoverage, $summary.CoveredLines, $summary.TotalLines, $summary.Modules) diff --git a/.github/skills/code-coverage-calculation/scripts/lib/Build-FunctionCoverageTreeReport.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Build-FunctionCoverageTreeReport.ps1 new file mode 100644 index 000000000..9b6b756ff --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/lib/Build-FunctionCoverageTreeReport.ps1 @@ -0,0 +1,540 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Build-FunctionCoverageTreeReport.ps1 +# +# Renders a SINGLE, self-contained HTML file that extends Build-CoverageTreeReport.ps1's +# collapsible folder/file tree with one more level: expand a FILE row to reveal the individual +# C++ functions defined in it (from Get-FunctionCoverage.ps1's corrected-per-function.csv), each +# with its own covered/total lines and %. +# +# The folder/file levels (and their rollup percentages/coloring) are built from +# corrected-per-file.csv exactly like Build-CoverageTreeReport.ps1 - i.e. they still show the +# Azure-DevOps-verified, authoritative file/folder totals. The function level underneath a file +# is a separate, ADDITIONAL data source (corrected-per-function.csv) laid on top purely for +# drill-down - it is NOT summed into the file/folder rollup, and deliberately so: in C++, many +# distinct compiled functions (template instantiations, inlined helpers, macro expansions) can +# legitimately share the same physical source line, so summing per-function totals does not +# reproduce a file's true unique-line total (see Get-FunctionCoverage.ps1's header comment for +# the full explanation and a worked example). This report keeps both views honest by computing +# them independently and never mixing one into the other. +# +# PERFORMANCE: a full WinUI build can have 250k+ functions. Folder/file rows are built eagerly +# (same as Build-CoverageTreeReport.ps1 - a few thousand nodes, cheap), but each file's function +# list is only built into the DOM the first time that file row is expanded (lazy), so the report +# stays fast to load even though the underlying embedded JSON contains every function. +# +# OUTPUT: exactly one .html file - no external assets, works fully offline. +# +# EXAMPLE +# .\Build-FunctionCoverageTreeReport.ps1 ` +# -InputPerFileCsv CorrectedReport\corrected-per-file.csv ` +# -InputPerFunctionCsv FunctionCoverage\corrected-per-function.csv ` +# -InputSummaryJson CorrectedReport\corrected-summary.json ` +# -OutputDir FunctionCoverage\TreeReport ` +# -ModuleNames Microsoft.ui.xaml.dll + +[CmdletBinding()] +param( + # Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1 / + # Split-CoverageByModule.ps1. Drives the folder/file levels of the tree (authoritative + # totals - matches the Azure DevOps Code Coverage tab). + [Parameter(Mandatory = $true)] + [string]$InputPerFileCsv, + + # Path to corrected-per-function.csv, as produced by Get-FunctionCoverage.ps1. Drives the + # function-level drill-down nested under each file row. + [Parameter(Mandatory = $true)] + [string]$InputPerFunctionCsv, + + # Path to corrected-summary.json (for the overall KPI header). Optional - if omitted or + # missing, the KPI header is computed from the per-file rows instead. + [string]$InputSummaryJson = "", + + # Folder to write the single HTML report into. Created if it doesn't exist. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Report title shown at the top of the page. + [string]$Title = "Function Coverage Report", + + # Regex matched against the START of each file path and stripped before display - same + # convention/default as Build-CoverageTreeReport.ps1. Applied identically to both input CSVs + # so file paths line up between them. + [string]$StripPathPrefix = '^[A-Za-z]:[\\/]+(__w|a)[\\/]+\d+[\\/]+s[\\/]+', + + # Output file name. + [string]$OutputFileName = 'function-coverage-tree.html', + + # Name(s) of the instrumented product DLL(s) this coverage measures. Same semantics as + # Build-CoverageTreeReport.ps1's -ModuleNames. + [string[]]$ModuleNames = @() +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $InputPerFileCsv)) { throw "Input per-file CSV not found: $InputPerFileCsv" } +if (-not (Test-Path $InputPerFunctionCsv)) { throw "Input per-function CSV not found: $InputPerFunctionCsv" } +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "Building single-file function-drill-down tree report..." +Write-Host " Per-file (authoritative): $InputPerFileCsv" +Write-Host " Per-function (drill-down): $InputPerFunctionCsv" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +$fileRows = Import-Csv $InputPerFileCsv +if ($fileRows.Count -eq 0) { throw "No rows found in $InputPerFileCsv - nothing to render." } +$functionRows = Import-Csv $InputPerFunctionCsv +Write-Host ("Loaded {0:N0} file row(s) and {1:N0} function row(s)." -f $fileRows.Count, $functionRows.Count) + +$stripRegex = $null +if ($StripPathPrefix) { + try { $stripRegex = New-Object System.Text.RegularExpressions.Regex($StripPathPrefix, 'IgnoreCase') } + catch { Write-Host "WARNING: -StripPathPrefix is not a valid regex ('$StripPathPrefix'); paths will be shown unmodified. $($_.Exception.Message)" -ForegroundColor Yellow } +} + +function Get-NormalizedPath { + param([string]$Path) + $p = $Path + if ($stripRegex -and $stripRegex.IsMatch($p)) { $p = $stripRegex.Replace($p, '', 1) } + return ($p -replace '/', '\') +} + +# --------------------------------------------------------------------------------------------- +# 1. Group corrected-per-function.csv by (normalized) file path, sorting each file's own +# function list worst-covered-first (most useful for spotting gaps) - ties broken by name. +# Kept as plain arrays of compact records; NOT summed into anything - purely for drill-down. +# --------------------------------------------------------------------------------------------- +Write-Host "Grouping functions by file..." +$functionsByFile = @{} +foreach ($fr in $functionRows) { + $key = Get-NormalizedPath -Path $fr.File + if (-not $functionsByFile.ContainsKey($key)) { $functionsByFile[$key] = New-Object System.Collections.Generic.List[object] } + $total = [int]$fr.TotalLines + $covered = [int]$fr.CoveredLines + $pct = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + $displayName = if ($fr.Type -and $fr.Type -ne '') { "$($fr.Type)::$($fr.Function)" } else { $fr.Function } + $tooltip = "$($fr.Namespace)::$($fr.Type)::$($fr.Function)" -replace '^::+', '' + [void]$functionsByFile[$key].Add([pscustomobject]@{ + n = $displayName + q = $tooltip + c = $covered + t = $total + p = $pct + }) +} +foreach ($k in @($functionsByFile.Keys)) { + $functionsByFile[$k] = @($functionsByFile[$k] | Sort-Object p, n) +} +Write-Host ("Grouped functions for {0:N0} distinct file(s)." -f $functionsByFile.Count) + +# --------------------------------------------------------------------------------------------- +# 2. Build the folder/file tree from corrected-per-file.csv - identical logic/rollup to +# Build-CoverageTreeReport.ps1 - then attach each file node's function list (if any) as a +# separate 'fk' (function-kids) property, kept apart from folder 'k' children. +# --------------------------------------------------------------------------------------------- +function New-TreeNode { + param([string]$Name, [bool]$IsFile) + [pscustomobject]@{ + Name = $Name + IsFile = $IsFile + Covered = 0 + Total = 0 + Functions = $null + Children = [ordered]@{} + } +} + +$root = New-TreeNode -Name '' -IsFile $false +$strippedCount = 0 +$totalCount = 0 + +foreach ($row in $fileRows) { + $totalCount++ + $rawPath = $row.File + $path = Get-NormalizedPath -Path $rawPath + if ($path -ne $rawPath) { $strippedCount++ } + $segments = $path.Split('\') | Where-Object { $_ -ne '' } + if ($segments.Count -eq 0) { continue } + + $covered = [int]$row.CoveredLines + $total = [int]$row.TotalLines + + $node = $root + for ($i = 0; $i -lt $segments.Count; $i++) { + $seg = $segments[$i] + $isLeaf = ($i -eq $segments.Count - 1) + if (-not $node.Children.Contains($seg)) { + $node.Children[$seg] = New-TreeNode -Name $seg -IsFile $isLeaf + } + $node = $node.Children[$seg] + } + $node.Covered = $covered + $node.Total = $total + $fns = $null + if ($functionsByFile.ContainsKey($path)) { $fns = $functionsByFile[$path] } + $node.Functions = $fns +} + +Write-Host ("Stripped the boilerplate prefix from {0}/{1} file paths." -f $strippedCount, $totalCount) + +function Get-Rollup { + param($Node) + if ($Node.IsFile) { return } + $c = 0; $t = 0 + foreach ($child in $Node.Children.Values) { + Get-Rollup -Node $child + $c += $child.Covered + $t += $child.Total + } + $Node.Covered = $c + $Node.Total = $t +} +Get-Rollup -Node $root + +function ConvertTo-JsonNode { + param($Node) + $pct = if ($Node.Total -gt 0) { [math]::Round(100.0 * $Node.Covered / $Node.Total, 2) } else { 0 } + $obj = [ordered]@{ + n = $Node.Name + c = $Node.Covered + t = $Node.Total + p = $pct + } + if ($Node.IsFile) { + $obj['f'] = 1 + if ($Node.Functions -and $Node.Functions.Count -gt 0) { $obj['fk'] = $Node.Functions } + } else { + $sortedChildren = $Node.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } + $obj['k'] = @($sortedChildren | ForEach-Object { ConvertTo-JsonNode -Node $_ }) + } + return $obj +} + +$treeData = @($root.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } | ForEach-Object { ConvertTo-JsonNode -Node $_ }) +$treeJson = $treeData | ConvertTo-Json -Depth 100 -Compress +if ($treeData.Count -eq 1) { $treeJson = "[$treeJson]" } + +# --------------------------------------------------------------------------------------------- +# 3. KPI header - file-level KPIs from the (already-verified) summary/per-file rows, plus new +# function-level KPIs computed straight from corrected-per-function.csv. +# --------------------------------------------------------------------------------------------- +$summary = $null +if ($InputSummaryJson -and (Test-Path $InputSummaryJson)) { + $summary = Get-Content $InputSummaryJson -Raw | ConvertFrom-Json +} +if (-not $summary) { + $totalLines = ($fileRows | Measure-Object -Property TotalLines -Sum).Sum + $coveredLines = ($fileRows | Measure-Object -Property CoveredLines -Sum).Sum + $summary = [pscustomobject]@{ + Modules = $fileRows.Count + TotalLines = $totalLines + CoveredLines = $coveredLines + LineCoverage = if ($totalLines -gt 0) { [math]::Round(100.0 * $coveredLines / $totalLines, 2) } else { 0 } + } +} + +$totalFunctions = $functionRows.Count +$functionsWithCoverage = @($functionRows | Where-Object { [int]$_.CoveredLines -gt 0 }).Count +$functionsFullyCovered = @($functionRows | Where-Object { [int]$_.TotalLines -gt 0 -and [int]$_.CoveredLines -eq [int]$_.TotalLines }).Count + +$resolvedModuleNames = @($ModuleNames) +if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') { + $resolvedModuleNames = @($summary.InstrumentedModules) +} +$moduleDisplay = if ($resolvedModuleNames -and $resolvedModuleNames.Count -gt 0) { ($resolvedModuleNames -join ', ') } else { '' } + +$summaryObj = [ordered]@{ + LineCoverage = $summary.LineCoverage + CoveredLines = $summary.CoveredLines + TotalLines = $summary.TotalLines + Modules = $summary.Modules + TotalFunctions = $totalFunctions + FunctionsWithCoverage = $functionsWithCoverage + FunctionsFullyCovered = $functionsFullyCovered +} +$summaryJson = ($summaryObj | ConvertTo-Json -Compress) + +# --------------------------------------------------------------------------------------------- +# HTML template. Single-quoted here-string (no PowerShell variable expansion). +# --------------------------------------------------------------------------------------------- +$htmlTemplate = @' + + + + +__TITLE__ + + + +

__TITLE__

+__MODULE_LINE__ +
Generated __GENERATED_AT__ · boilerplate path prefix stripped from every entry below
+ +
+ +
Folder/file percentages and line totals are the authoritative, Azure-DevOps-verified numbers. Expand a file to see its individual functions - each function's own covered/total lines are shown for that function alone, and are not summed into the file total above it (in C++, distinct functions such as template instantiations can legitimately share the same physical source line, so a naive sum would double-count).
+ +
+ ≥ 75% + 40–75% + < 40% + ƒ = function (click a file with a caret to reveal its functions) + Search filters folders/files only +
+ + + +
+ +
Self-contained report - no external files. File/folder coverage matches the Azure DevOps Code Coverage tab exactly. Function-level detail comes from the coverage merge engine's native (non-Cobertura) output format, which preserves per-function boundaries that Cobertura collapses away.
+ + + + +'@ + +$outFile = Join-Path $OutputDir $OutputFileName +$moduleLineHtml = if ($moduleDisplay) { + '
Code coverage measured for: ' + [System.Net.WebUtility]::HtmlEncode($moduleDisplay) + '
' +} else { + '' +} +$html = $htmlTemplate.Replace('__TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)) +$html = $html.Replace('__MODULE_LINE__', $moduleLineHtml) +$html = $html.Replace('__GENERATED_AT__', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) +$html = $html.Replace('__TREE_JSON__', $treeJson) +$html = $html.Replace('__SUMMARY_JSON__', $summaryJson) + +Set-Content -Path $outFile -Value $html -Encoding UTF8 + +Write-Host ("`nWrote single-file function-drill-down tree report in {0:N1}s -> {1} ({2:N1} MB)" -f $sw.Elapsed.TotalSeconds, $outFile, ((Get-Item $outFile).Length / 1MB)) +if ($moduleDisplay) { Write-Host (" Module(s): {0}" -f $moduleDisplay) } +Write-Host (" File-level: {0}% ({1} / {2} lines across {3} files)" -f $summary.LineCoverage, $summary.CoveredLines, $summary.TotalLines, $summary.Modules) +Write-Host (" Functions: {0:N0} total, {1:N0} with any coverage, {2:N0} fully covered" -f $totalFunctions, $functionsWithCoverage, $functionsFullyCovered) diff --git a/.github/skills/code-coverage-calculation/scripts/lib/Get-FunctionCoverage.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Get-FunctionCoverage.ps1 new file mode 100644 index 000000000..5c3aab5da --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/lib/Get-FunctionCoverage.ps1 @@ -0,0 +1,349 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Get-FunctionCoverage.ps1 +# +# Produces a FUNCTION-LEVEL coverage breakdown ("which functions in this file were exercised by +# the tests, and by how much") - one row per C++ function/overload, not just one row per source +# file. This is a companion to (not a replacement for) Repair-CoverageDuplicates.ps1, which only +# ever reconstructs FILE-level totals because Cobertura (the format that script's input is in) +# collapses per-function detail down to one per source file. To get real function +# boundaries this script uses a different raw input: the coverage merge engine's native "xml" +# output format (`dotnet-coverage merge -f xml` / `Microsoft.CodeCoverage.Console merge -f xml`), +# which - unlike Cobertura - keeps one element per C++ function/template instantiation +# (name, namespace, containing class/type, and its own per-line hit/miss list), with a +# table mapping each line back to a real source path. +# +# THIS SCRIPT WORKS DIRECTLY FROM THE RAW PER-SLICE .coverage FILES - it does NOT need the +# product DLL/PDB. That symbol info (function names, namespaces, source file + line numbers) is +# already embedded in each .coverage file at collection time; this was explicitly verified by +# stripping the DLL/PDB out of a test run folder and re-merging - the function-level XML still +# resolved perfectly. +# +# PER-FUNCTION DEDUPLICATION: within a single function's own list, this script keeps the +# max covered-state (yes > partial > no) seen per (source file, line number) - guarding against +# the same duplicate-range-on-merge defect Repair-CoverageDuplicates.ps1 fixes for Cobertura, in +# case it ever resurfaces for a given function. In practice, `dotnet-coverage merge -f xml` was +# observed to already correctly union same-function-id ranges across contributing slices without +# duplication (a real build's `AccessKey.cpp`, merged from all 20 slices, summed its 8 functions' +# corrected totals to exactly 52 - matching that file's independently-verified true line count) - +# so this per-function dedup is mostly a defensive safety net, not something that was seen to +# fire heavily in practice. +# +# IMPORTANT NUANCE - per-function totals do NOT sum to the file's true unique-line total, and +# THAT IS EXPECTED, NOT A BUG: in C++, many distinct COMPILED functions can legitimately share +# the same physical source line - template instantiations (e.g. `_tlgCreate1Sz` and +# `_tlgCreate1Sz` are two separate compiled functions that both report the identical 9 +# header lines of their shared template body), inlined helpers, and macro expansions are the +# common causes. Verified on a real build: TraceLoggingProvider.h has 163 distinct instantiated +# functions whose own totals sum to 593 lines, even though the header itself has far fewer +# physical lines - each instantiation is legitimately "using" the same textual lines as its +# siblings. Because of this, DO NOT use this script's per-function rows to re-derive a file's +# true aggregate line coverage - use the existing, Azure-DevOps-verified `corrected-per-file.csv` +# (from Repair-CoverageDuplicates.ps1 / Split-CoverageByModule.ps1) for that; pass it via +# -ExistingPerFileCsv to have this script merge those authoritative file totals alongside the new +# per-function/function-count breakdown in corrected-per-file-function-summary.csv. +# +# OUTPUT (under -OutputDir) +# NativeMerge\merged-native.xml - raw dotnet-coverage -f xml merge. +# corrected-per-function.csv - one row per function: File, Namespace, Type, Function, +# TotalLines, CoveredLines, FullyCoveredLines, +# PartiallyCoveredLines, NotCoveredLines, +# LineCoveragePct (all "own view" totals for that one +# compiled function - see the nuance above before +# summing this column across functions). +# Sorted by File then Function. +# corrected-per-file-function-summary.csv - one row per source file: TotalFunctions, +# FunctionsWithAnyCoverage, FunctionsFullyCovered, +# FunctionsNotCovered - plus, only when +# -ExistingPerFileCsv is supplied, that file's +# authoritative FileTotalLines/FileCoveredLines/ +# FileLineCoveragePct joined in from corrected-per-file.csv. +# +# EXAMPLE (all .coverage files under a folder, joining in the authoritative per-file totals that +# Split-CoverageByModule.ps1 already computed for them) +# .\Get-FunctionCoverage.ps1 -SliceDir C:\cov\run1\raw ` +# -OutputDir C:\cov\run1\FunctionCoverage ` +# -ExistingPerFileCsv C:\cov\run1\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv +# +# EXAMPLE (explicit list of .coverage files) +# .\Get-FunctionCoverage.ps1 -CoverageFiles file1.coverage,file2.coverage -OutputDir .\out + +[CmdletBinding(DefaultParameterSetName = 'FromSliceDir')] +param( + # Directory to search recursively for raw *.coverage files (e.g. a folder holding one + # .coverage file per test run or slice). All matches are merged together. + [Parameter(Mandatory = $true, ParameterSetName = 'FromSliceDir')] + [string]$SliceDir, + + # Explicit list of raw .coverage files to merge (alternative to -SliceDir). + [Parameter(Mandatory = $true, ParameterSetName = 'FromFiles')] + [string[]]$CoverageFiles, + + # Where to write outputs. Created if it doesn't exist. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Explicit path to dotnet-coverage / Microsoft.CodeCoverage.Console.exe. Auto-resolved + # (dotnet-coverage on PATH, else VS's Microsoft.CodeCoverage.Console.exe, else auto-installs + # dotnet-coverage) if not supplied. + [string]$CoverageToolPath = "", + + # Reuse an already-produced NativeMerge\merged-native.xml from a prior run instead of + # re-merging (useful for iterating on the dedup/CSV logic without re-running the - often + # multi-minute - merge step). + [switch]$SkipMerge, + + # Count a "partially covered" line (some but not all blocks on that line executed - common + # with multiple statements/branches per line in C++) as covered when computing CoveredLines + # and LineCoveragePct. On by default (matches the common-sense "was this line touched at + # all" reading). Fully-covered and partially-covered are still also broken out into their + # own separate columns regardless of this switch, so no information is lost either way. + [bool]$TreatPartialAsCovered = $true, + + # Keep NativeMerge\merged-native.xml after the run instead of leaving it (it is NOT deleted + # by default - this file is the expensive-to-regenerate one, so it is kept unless this switch + # is explicitly reversed by passing -KeepIntermediateXml:$false). + [bool]$KeepIntermediateXml = $true, + + # Optional path to an existing corrected-per-file.csv (as produced by + # Repair-CoverageDuplicates.ps1 / Split-CoverageByModule.ps1). When supplied, that file's + # already Azure-DevOps-verified per-file TotalLines/CoveredLines/LineCoverage are joined into + # corrected-per-file-function-summary.csv as FileTotalLines/FileCoveredLines/ + # FileLineCoveragePct, alongside this script's own function-count columns. Recommended - + # without it, the summary only has function counts (no line totals), by design, to avoid + # presenting a second, non-equivalent "file total" next to the one everyone already trusts + # (see the "IMPORTANT NUANCE" note above). + [string]$ExistingPerFileCsv = "" +) + +$ErrorActionPreference = 'Stop' + +$nativeMergeDir = Join-Path $OutputDir 'NativeMerge' +New-Item -ItemType Directory -Force -Path $OutputDir, $nativeMergeDir | Out-Null +$nativeXmlPath = Join-Path $nativeMergeDir 'merged-native.xml' + +# --------------------------------------------------------------------------------------------- +# 1. Resolve a coverage merge tool (dotnet-coverage on PATH, else VS's console tool, else install). +# --------------------------------------------------------------------------------------------- +function Resolve-CoverageTool { + param([string]$Explicit) + if ($Explicit -and (Test-Path $Explicit)) { return $Explicit } + $dc = Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue + if ($dc) { return $dc.Source } + $vs = Get-ChildItem -Path @( + "${env:ProgramFiles}\Microsoft Visual Studio", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio" + ) -Recurse -Filter 'Microsoft.CodeCoverage.Console.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($vs) { return $vs.FullName } + Write-Host "dotnet-coverage not found; installing the global tool..." + & dotnet tool install --global dotnet-coverage --add-source https://api.nuget.org/v3/index.json --ignore-failed-sources | Write-Host + $env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools" + $dc = Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue + if ($dc) { return $dc.Source } + throw "Could not find or install a code coverage merge tool (dotnet-coverage or Microsoft.CodeCoverage.Console.exe)." +} + +# --------------------------------------------------------------------------------------------- +# 2. Merge raw .coverage files into the native (function-granular) xml format. +# --------------------------------------------------------------------------------------------- +if (-not $SkipMerge) { + $inputFiles = if ($PSCmdlet.ParameterSetName -eq 'FromSliceDir') { + Write-Host "=== Discovering raw .coverage files under $SliceDir ===" + @(Get-ChildItem $SliceDir -Recurse -Filter '*.coverage' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + } else { + @($CoverageFiles) + } + if ($inputFiles.Count -eq 0) { + throw "No .coverage files found (checked $(if ($PSCmdlet.ParameterSetName -eq 'FromSliceDir') { $SliceDir } else { '-CoverageFiles list' }))." + } + Write-Host "Merging $($inputFiles.Count) raw .coverage file(s) into the native function-granular xml format..." + Write-Host "(This does NOT require the product DLL/PDB - function/line symbol info is already embedded in each .coverage file.)" + + $covTool = Resolve-CoverageTool -Explicit $CoverageToolPath + Write-Host "Using: $covTool" + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + & $covTool merge @inputFiles -f xml -o $nativeXmlPath --nologo | Write-Host + if ($LASTEXITCODE -ne 0) { throw "Native xml merge failed (exit $LASTEXITCODE)." } + Write-Host ("Merge complete in {0:N1}s -> {1} ({2:N0} MB)" -f $sw.Elapsed.TotalSeconds, $nativeXmlPath, ((Get-Item $nativeXmlPath).Length / 1MB)) + Write-Host "NOTE: this raw merged file still has the duplicate-range bug - do not read its / line/block-rate attributes directly; this script re-derives corrected values below." +} else { + if (-not (Test-Path $nativeXmlPath)) { throw "-SkipMerge was set but $nativeXmlPath does not exist." } + Write-Host "=== -SkipMerge set: reusing existing $nativeXmlPath ===" +} + +# --------------------------------------------------------------------------------------------- +# 3. Stream-parse the native xml once, deduplicating as we go: +# - source_files: source_id -> file path +# - functions: function id -> aggregated { Name, Namespace, TypeName, per-(sourceId,line) +# max covered-state, per-sourceId line-count (to pick the function's "primary" file) } +# A single forward-only XmlReader pass keeps this feasible on multi-hundred-MB inputs without +# loading a full DOM. +# --------------------------------------------------------------------------------------------- +Write-Host "`n=== Parsing and deduplicating $nativeXmlPath ===" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +Add-Type -Language CSharp -TypeDefinition @" +using System.Collections.Generic; +public class FuncAgg { + public string Name; + public string Namespace; + public string TypeName; + // "sourceId|line" -> state (2=yes, 1=partial, 0=no) + public Dictionary LineStates = new Dictionary(); + // sourceId -> count of distinct lines seen for that source, used to pick the "primary" file. + public Dictionary SourceLineCounts = new Dictionary(); +} +"@ + +$sourceFiles = New-Object 'System.Collections.Generic.Dictionary[string,string]' +$functions = New-Object 'System.Collections.Generic.Dictionary[string,FuncAgg]' + +$reader = [System.Xml.XmlReader]::Create($nativeXmlPath) +$currentFunc = $null +$rawFunctionElementCount = 0 +$rawRangeElementCount = 0 +try { + while ($reader.Read()) { + if ($reader.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } + + switch ($reader.Name) { + 'source_file' { + $id = $reader.GetAttribute('id') + $path = $reader.GetAttribute('path') + if ($id -and -not $sourceFiles.ContainsKey($id)) { $sourceFiles[$id] = $path } + } + 'function' { + $rawFunctionElementCount++ + $id = $reader.GetAttribute('id') + $agg = $null + if (-not $functions.TryGetValue($id, [ref]$agg)) { + $agg = New-Object FuncAgg + $agg.Name = $reader.GetAttribute('name') + $agg.Namespace = $reader.GetAttribute('namespace') + $agg.TypeName = $reader.GetAttribute('type_name') + $functions[$id] = $agg + } + $currentFunc = $agg + } + 'range' { + $rawRangeElementCount++ + if (-not $currentFunc) { continue } + $sid = $reader.GetAttribute('source_id') + $sln = [int]$reader.GetAttribute('start_line') + $eln = [int]$reader.GetAttribute('end_line') + $cov = $reader.GetAttribute('covered') + $state = if ($cov -eq 'yes') { 2 } elseif ($cov -eq 'partial') { 1 } else { 0 } + for ($ln = $sln; $ln -le $eln; $ln++) { + $key = "$sid|$ln" + $existing = 0 + if ($currentFunc.LineStates.TryGetValue($key, [ref]$existing)) { + if ($state -gt $existing) { $currentFunc.LineStates[$key] = $state } + } else { + $currentFunc.LineStates[$key] = $state + $cnt = 0 + [void]$currentFunc.SourceLineCounts.TryGetValue($sid, [ref]$cnt) + $currentFunc.SourceLineCounts[$sid] = $cnt + 1 + } + } + } + } + } +} finally { + $reader.Close() +} +Write-Host ("Parsed {0:N0} raw elements ({1:N0} unique) and {2:N0} raw elements in {3:N1}s." -f $rawFunctionElementCount, $functions.Count, $rawRangeElementCount, $sw.Elapsed.TotalSeconds) + +# --------------------------------------------------------------------------------------------- +# 4. Compute corrected per-function totals, attributing each function to its "primary" file +# (whichever source_id contributed the most distinct lines) and only counting that file's +# lines toward the function's own totals - this keeps a constructor's one stray reference to +# a header's default-member-initializer line, for example, from polluting its own .cpp totals. +# --------------------------------------------------------------------------------------------- +Write-Host "`n=== Computing corrected per-function totals ===" +$perFunctionRows = New-Object System.Collections.Generic.List[object] +foreach ($kvp in $functions.GetEnumerator()) { + $agg = $kvp.Value + if ($agg.SourceLineCounts.Count -eq 0) { continue } + $primarySourceId = ($agg.SourceLineCounts.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1).Key + $filePath = $null + if (-not $sourceFiles.TryGetValue($primarySourceId, [ref]$filePath)) { $filePath = "" } + + $total = 0; $fully = 0; $partial = 0 + foreach ($entry in $agg.LineStates.GetEnumerator()) { + if (-not $entry.Key.StartsWith("$primarySourceId|")) { continue } + $total++ + if ($entry.Value -eq 2) { $fully++ } + elseif ($entry.Value -eq 1) { $partial++ } + } + $notCovered = $total - $fully - $partial + $covered = if ($TreatPartialAsCovered) { $fully + $partial } else { $fully } + $pct = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + + $perFunctionRows.Add([pscustomobject]@{ + File = $filePath + Namespace = $agg.Namespace + Type = $agg.TypeName + Function = $agg.Name + TotalLines = $total + CoveredLines = $covered + FullyCoveredLines = $fully + PartiallyCoveredLines = $partial + NotCoveredLines = $notCovered + LineCoveragePct = $pct + }) +} +Write-Host "Computed totals for $($perFunctionRows.Count) function(s)." + +# --------------------------------------------------------------------------------------------- +# 5. Roll up to a per-file function summary. This intentionally reports FUNCTION COUNTS only +# (not a re-derived line total - see the "IMPORTANT NUANCE" note at the top of this script for +# why summing per-function line totals is not a valid stand-in for a file's true unique-line +# total in C++). If -ExistingPerFileCsv was supplied, that already-verified file-level total +# is joined in alongside the function counts. +# --------------------------------------------------------------------------------------------- +Write-Host "`n=== Rolling up to per-file function summary ===" +$existingPerFile = $null +if ($ExistingPerFileCsv) { + if (-not (Test-Path $ExistingPerFileCsv)) { throw "-ExistingPerFileCsv path not found: $ExistingPerFileCsv" } + $existingPerFile = @{} + foreach ($row in (Import-Csv $ExistingPerFileCsv)) { $existingPerFile[$row.File] = $row } + Write-Host "Joining authoritative file totals from $ExistingPerFileCsv ($($existingPerFile.Count) file(s))." +} + +$perFileSummary = $perFunctionRows | Group-Object -Property File | ForEach-Object { + $rows = $_.Group + $row = [ordered]@{ + File = $_.Name + TotalFunctions = $rows.Count + FunctionsWithAnyCoverage = ($rows | Where-Object { $_.CoveredLines -gt 0 }).Count + FunctionsFullyCovered = ($rows | Where-Object { $_.TotalLines -gt 0 -and $_.CoveredLines -eq $_.TotalLines }).Count + FunctionsNotCovered = ($rows | Where-Object { $_.CoveredLines -eq 0 }).Count + } + if ($existingPerFile) { + $match = if ($existingPerFile.ContainsKey($_.Name)) { $existingPerFile[$_.Name] } else { $null } + $row['FileTotalLines'] = if ($match) { $match.TotalLines } else { '' } + $row['FileCoveredLines'] = if ($match) { $match.CoveredLines } else { '' } + $row['FileLineCoveragePct'] = if ($match) { $match.LineCoverage } else { '' } + } + [pscustomobject]$row +} + +# --------------------------------------------------------------------------------------------- +# 6. Write outputs. +# --------------------------------------------------------------------------------------------- +$perFunctionCsvPath = Join-Path $OutputDir 'corrected-per-function.csv' +$perFileSummaryCsvPath = Join-Path $OutputDir 'corrected-per-file-function-summary.csv' + +$perFunctionRows | Sort-Object File, Function | Export-Csv $perFunctionCsvPath -NoTypeInformation +$perFileSummary | Sort-Object File | Export-Csv $perFileSummaryCsvPath -NoTypeInformation + +if (-not $KeepIntermediateXml) { + Remove-Item $nativeXmlPath -Force -ErrorAction SilentlyContinue +} + +Write-Host "`n=== Done ===" +Write-Host " $perFunctionCsvPath" +Write-Host " $perFileSummaryCsvPath" +if ($KeepIntermediateXml) { Write-Host " $nativeXmlPath (kept; pass -KeepIntermediateXml `$false to discard)" } diff --git a/.github/skills/code-coverage-calculation/scripts/lib/Repair-CoverageDuplicates.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Repair-CoverageDuplicates.ps1 new file mode 100644 index 000000000..9268a7e66 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/lib/Repair-CoverageDuplicates.ps1 @@ -0,0 +1,231 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Repair-CoverageDuplicates.ps1 +# +# Fixes a real, verified defect in Microsoft's native (C++ static-instrumentation) code coverage +# merge engine - the same engine backing BOTH the `dotnet-coverage` global tool and Visual +# Studio's bundled `Microsoft.CodeCoverage.Console.exe`. +# +# THE BUG: when ` merge -f cobertura` merges multiple raw .coverage +# files that all instrument the SAME native module (e.g. one module instrumented once, then +# exercised by N separate collector processes on N separate machines - exactly what happens when +# a test pass is split into parallel slices with one collector per slice, or when one module is +# collected by several separate test runs), the merge +# does NOT consolidate matching (file, line-number) entries into one. Instead it appends a +# fresh copy of that class's elements per contributing input file. This was verified to +# be 100% systemic (every single element in a real merged report exhibited it) and to +# exist even in Visual Studio's own native (non-Cobertura) merge output - so it is a merge-time +# defect, not a Cobertura-serialization-only quirk. +# +# EFFECT: any tool that iterates every element and sums them (dotnet-coverage's own +# printed merge summary, reportgenerator's TextSummary, etc.) overcounts both total and covered +# lines, understating the true coverage percentage. Azure DevOps's own "Code Coverage" build tab +# is NOT affected, because it deduplicates by (file, line-number) when it renders - this script +# reproduces that same deduplication so a locally-computed number matches the ADO tab exactly. +# (Verified: deduplicating a real build's merged.cobertura.xml this way reproduced the ADO tab's +# 4,500 files / 451,305 total lines / 337,879 covered lines / 74.87% exactly.) +# +# THE FIX (this script): parse the buggy merged Cobertura XML, and for every unique source file +# (grouping across ALL elements that map to it - some files have many classes, e.g. one +# per C++ template instantiation, that all reference the same physical source lines), keep only +# one entry per unique line number (hits = max across duplicates). Emit: +# 1. corrected-summary.json / corrected-summary.csv - overall + per-file corrected totals. +# 2. corrected.cobertura.xml - a schema-valid Cobertura file (one per source file, +# already deduplicated) that any standard Cobertura consumer (reportgenerator, ADO's own +# publish task, etc.) will read back with the SAME correct numbers. +# +# This script is self-contained and works on ANY Cobertura XML produced by dotnet-coverage / +# Microsoft.CodeCoverage.Console's merge. + +[CmdletBinding()] +param( + # Path to the (buggy, duplicate-line) merged Cobertura XML to repair. + [Parameter(Mandatory = $true)] + [string]$InputCobertura, + + # Where to write corrected-summary.json/.csv and corrected.cobertura.xml. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Name(s) of the product DLL(s) that were instrumented to produce this coverage data (e.g. + # "Microsoft.ui.xaml.dll", or multiple space-separated names if more than one module was + # instrumented). Purely descriptive - recorded in corrected-summary.json's + # "InstrumentedModules" field and used as the in the corrected Cobertura + # output, so downstream reports (e.g. Build-CoverageTreeReport.ps1's HTML) can show which + # binary this coverage percentage actually measures. Optional - falls back to a generic + # placeholder if not supplied. + [string[]]$ModuleNames = @() +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Xml + +if (-not (Test-Path $InputCobertura)) { + throw "Input Cobertura file not found: $InputCobertura" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "Parsing and deduplicating $InputCobertura ..." +Write-Host "(This streams the XML rather than loading a full DOM, so large multi-hundred-MB merges are fine.)" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +# fileName -> Dictionary (hits = max seen across all duplicate/class entries) +$fileLineMap = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[int,int]]' +# Preserve first-seen file order for stable, readable output. +$fileOrder = New-Object 'System.Collections.Generic.List[string]' + +$reader = [System.Xml.XmlReader]::Create($InputCobertura) +$currentDict = $null +$classElementCount = 0 +$rawLineElementCount = 0 +try { + while ($reader.Read()) { + if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'class') { + $classElementCount++ + $fn = $reader.GetAttribute('filename') + if (-not $fn) { $fn = $reader.GetAttribute('name') } + if (-not $fileLineMap.TryGetValue($fn, [ref]$currentDict)) { + $currentDict = New-Object 'System.Collections.Generic.Dictionary[int,int]' + $fileLineMap[$fn] = $currentDict + $fileOrder.Add($fn) + } + } + elseif ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'line') { + $rawLineElementCount++ + $num = [int]$reader.GetAttribute('number') + $hits = [int]$reader.GetAttribute('hits') + $existing = 0 + if ($currentDict.TryGetValue($num, [ref]$existing)) { + if ($hits -gt $existing) { $currentDict[$num] = $hits } + } else { + $currentDict[$num] = $hits + } + } + } +} finally { + $reader.Close() +} + +Write-Host ("Parsed {0} raw elements and {1} raw elements in {2:N1}s." -f $classElementCount, $rawLineElementCount, $sw.Elapsed.TotalSeconds) + +# Aggregate corrected per-file and overall totals. +$perFile = New-Object System.Collections.Generic.List[object] +$overallTotal = 0 +$overallCovered = 0 +foreach ($fn in $fileOrder) { + $dict = $fileLineMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $overallTotal += $total + $overallCovered += $covered + $perFile.Add([pscustomobject]@{ + File = $fn + CoveredLines = $covered + TotalLines = $total + LineCoverage = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + }) +} + +$overallRate = if ($overallTotal -gt 0) { $overallCovered / $overallTotal } else { 0 } +$moduleDisplayName = if ($ModuleNames -and $ModuleNames.Count -gt 0) { $ModuleNames -join ', ' } else { '(not specified)' } + +$summary = [pscustomobject]@{ + Modules = $fileOrder.Count + TotalLines = $overallTotal + CoveredLines = $overallCovered + LineCoverage = [math]::Round(100.0 * $overallRate, 2) + InstrumentedModules = @($ModuleNames) + RawClassCount = $classElementCount + RawLineCount = $rawLineElementCount + SourceFile = (Resolve-Path $InputCobertura).Path + GeneratedAt = (Get-Date).ToString('o') +} + +$summaryJsonPath = Join-Path $OutputDir 'corrected-summary.json' +$summaryCsvPath = Join-Path $OutputDir 'corrected-summary.csv' +$perFileCsvPath = Join-Path $OutputDir 'corrected-per-file.csv' +$correctedXmlPath = Join-Path $OutputDir 'corrected.cobertura.xml' + +$summary | ConvertTo-Json | Set-Content $summaryJsonPath +$summary | Export-Csv $summaryCsvPath -NoTypeInformation +$perFile | Sort-Object File | Export-Csv $perFileCsvPath -NoTypeInformation + +Write-Host "`nWriting corrected, deduplicated Cobertura XML (one per source file) -> $correctedXmlPath" +$xmlSettings = New-Object System.Xml.XmlWriterSettings +$xmlSettings.Indent = $false +$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false) +$writer = [System.Xml.XmlWriter]::Create($correctedXmlPath, $xmlSettings) +try { + $writer.WriteStartDocument() + $writer.WriteStartElement('coverage') + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('lines-covered', $overallCovered) + $writer.WriteAttributeString('lines-valid', $overallTotal) + $writer.WriteAttributeString('branches-covered', '0') + $writer.WriteAttributeString('branches-valid', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteAttributeString('version', '1.9') + $writer.WriteAttributeString('timestamp', [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + + $writer.WriteStartElement('sources') + $writer.WriteElementString('source', '.') + $writer.WriteEndElement() # sources + + $writer.WriteStartElement('packages') + $writer.WriteStartElement('package') + $writer.WriteAttributeString('name', $moduleDisplayName) + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('classes') + + foreach ($fn in $fileOrder) { + $dict = $fileLineMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $rate = if ($total -gt 0) { $covered / $total } else { 0 } + + $writer.WriteStartElement('class') + $writer.WriteAttributeString('name', $fn) + $writer.WriteAttributeString('filename', $fn) + $writer.WriteAttributeString('line-rate', $rate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('methods') + $writer.WriteEndElement() # methods + $writer.WriteStartElement('lines') + foreach ($num in ($dict.Keys | Sort-Object)) { + $writer.WriteStartElement('line') + $writer.WriteAttributeString('number', $num) + $writer.WriteAttributeString('hits', $dict[$num]) + $writer.WriteAttributeString('branch', 'false') + $writer.WriteEndElement() # line + } + $writer.WriteEndElement() # lines + $writer.WriteEndElement() # class + } + + $writer.WriteEndElement() # classes + $writer.WriteEndElement() # package + $writer.WriteEndElement() # packages + $writer.WriteEndElement() # coverage + $writer.WriteEndDocument() +} finally { + $writer.Close() +} + +Write-Host "`n=== Corrected coverage (bug-fixed, matches Azure DevOps's Code Coverage tab) ===" +Write-Host (" Instrumented DLL(s): {0}" -f $moduleDisplayName) +Write-Host (" Modules (files): {0}" -f $summary.Modules) +Write-Host (" Total lines: {0}" -f $summary.TotalLines) +Write-Host (" Covered lines: {0}" -f $summary.CoveredLines) +Write-Host (" Line coverage: {0}%" -f $summary.LineCoverage) +Write-Host "`nWrote:" +Write-Host " $summaryJsonPath" +Write-Host " $summaryCsvPath" +Write-Host " $perFileCsvPath" +Write-Host " $correctedXmlPath" diff --git a/.github/skills/code-coverage-calculation/scripts/lib/Set-CoveragePipeAcl.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Set-CoveragePipeAcl.ps1 new file mode 100644 index 000000000..1224d7b41 --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/lib/Set-CoveragePipeAcl.ps1 @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Set-CoveragePipeAcl.ps1 - Put a NULL DACL on the code-coverage collector's named pipe. +# +# This sets the DACL in-process via P/Invoke, so no native helper has to be shipped to the +# test machine. TAEF's te.processhost runs at +# low integrity (Centennial); by default it cannot connect to the collector's pipe and +# coverage comes back empty with ACCESS_DENIED. A NULL DACL opens the gate. +# +# Usage: +# .\Set-CoveragePipeAcl.ps1 -SessionId aabbccdd11223344 +# .\Set-CoveragePipeAcl.ps1 -PipeName '\\.\pipe\CodeCoverage.pipe.aabbccdd11223344' + +[CmdletBinding(DefaultParameterSetName = 'SessionId')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'SessionId')] + [string]$SessionId, + + [Parameter(Mandatory = $true, ParameterSetName = 'PipeName')] + [string]$PipeName +) + +$ErrorActionPreference = 'Stop' + +if ($PSCmdlet.ParameterSetName -eq 'SessionId') { + $PipeName = "\\.\pipe\CodeCoverage.pipe.$SessionId" +} + +# P/Invoke: open the pipe with WRITE_DAC and hand it a NULL DACL via SetSecurityInfo. +# (Add-Type compiles this with the in-box .NET Framework C# compiler on any Windows agent.) +if (-not ('WinUI.Coverage.PipeAcl' -as [type])) { + Add-Type -Namespace 'WinUI.Coverage' -Name 'PipeAcl' -MemberDefinition @' + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + public static extern System.IntPtr CreateFileW( + string lpFileName, uint dwDesiredAccess, uint dwShareMode, System.IntPtr lpSecurityAttributes, + uint dwCreationDisposition, uint dwFlagsAndAttributes, System.IntPtr hTemplateFile); + + [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = false)] + public static extern uint SetSecurityInfo( + System.IntPtr handle, int ObjectType, uint SecurityInfo, + System.IntPtr psidOwner, System.IntPtr psidGroup, System.IntPtr pDacl, System.IntPtr pSacl); + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(System.IntPtr hObject); + + // Open an existing pipe for WRITE_DAC | READ_CONTROL and set a NULL (allow-all) DACL. + public static uint SetNullDacl(string pipeName) + { + const uint WRITE_DAC = 0x00040000; + const uint READ_CONTROL = 0x00020000; + const uint OPEN_EXISTING = 3; + const int SE_KERNEL_OBJECT = 6; + const uint DACL_SECURITY_INFORMATION = 0x00000004; + + System.IntPtr h = CreateFileW(pipeName, WRITE_DAC | READ_CONTROL, 0, System.IntPtr.Zero, + OPEN_EXISTING, 0, System.IntPtr.Zero); + if (h == new System.IntPtr(-1)) + { + throw new System.ComponentModel.Win32Exception( + System.Runtime.InteropServices.Marshal.GetLastWin32Error(), + "Failed to open pipe " + pipeName); + } + try + { + // pDacl = NULL with DACL_SECURITY_INFORMATION set => NULL DACL (everyone allowed). + return SetSecurityInfo(h, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, + System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero); + } + finally + { + CloseHandle(h); + } + } +'@ +} + +Write-Warning "Opening coverage pipe '$PipeName' with a NULL DACL - every local user can access it until the collector exits. Fine on an ephemeral CI agent; avoid running this on a shared machine." +Write-Host "Opening pipe: $PipeName" +$result = [WinUI.Coverage.PipeAcl]::SetNullDacl($PipeName) +if ($result -ne 0) { + throw "SetSecurityInfo failed on '$PipeName' with error $result" +} +Write-Host "Successfully set NULL DACL on pipe" diff --git a/.github/skills/code-coverage-calculation/scripts/lib/Split-CoverageByModule.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Split-CoverageByModule.ps1 new file mode 100644 index 000000000..66acfdefb --- /dev/null +++ b/.github/skills/code-coverage-calculation/scripts/lib/Split-CoverageByModule.ps1 @@ -0,0 +1,255 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Split-CoverageByModule.ps1 +# +# Companion to Repair-CoverageDuplicates.ps1. That script applies the duplicate-line dedup fix +# across the WHOLE merged Cobertura file, producing a single aggregate number for every +# instrumented module combined. When a coverage run instruments MORE THAN ONE product DLL (e.g. +# Microsoft.ui.xaml.dll AND Microsoft.UI.Xaml.Controls.dll), the raw merge keeps them in separate +# elements - this script preserves that split, applying the exact same +# (file, line-number) dedup INDEPENDENTLY per package/module, so each DLL gets its own +# ADO-tab-accurate coverage number and per-file breakdown. +# +# Output (under -OutputDir): +# \corrected-summary.json / .csv +# \corrected-per-file.csv +# \corrected.cobertura.xml +# per-module-summary.csv - one row per module, side by side +# +# NOTE on totals: summing the per-module line totals will NOT necessarily equal the combined +# (all-modules) total from Repair-CoverageDuplicates.ps1, because a source file (typically a +# shared header) can legitimately be compiled into more than one module - the combined run +# dedups that file's lines once globally, whereas each module counts its own copy. Both numbers +# are correct for what they measure; use the per-module number when asking "how well is THIS DLL +# covered". + +[CmdletBinding()] +param( + # Path to the (buggy, duplicate-line) merged Cobertura XML produced by + # `dotnet-coverage merge ... -f cobertura` / Microsoft.CodeCoverage.Console.exe. + [Parameter(Mandatory = $true)] + [string]$InputCobertura, + + # Where to write the per-module folders and per-module-summary.csv. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Optional filter: only emit modules whose matches one of these (matching is + # case-insensitive and ignores a trailing ".dll", so "Microsoft.UI.Xaml.Controls.dll" matches + # a package named "Microsoft.ui.xaml.controls"). Omit to emit every module found. + [string[]]$Modules = @() +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Xml + +if (-not (Test-Path $InputCobertura)) { + throw "Input Cobertura file not found: $InputCobertura" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +function Get-NormalizedModuleName { + param([string]$Name) + return ($Name -replace '\.dll$', '').ToLowerInvariant() +} + +$wanted = @() +foreach ($m in $Modules) { if ($m) { $wanted += (Get-NormalizedModuleName $m) } } + +Write-Host "Streaming and deduplicating (per module) $InputCobertura ..." +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +# module -> (file -> (line -> hits)) +$moduleMap = New-Object 'System.Collections.Generic.Dictionary[string,object]' +$moduleOrder = New-Object 'System.Collections.Generic.List[string]' +# module -> ordered file list +$moduleFileOrder = New-Object 'System.Collections.Generic.Dictionary[string,object]' + +$reader = [System.Xml.XmlReader]::Create($InputCobertura) +$currentModule = $null +$currentFileMap = $null +$currentFileOrder = $null +$currentDict = $null +$classCount = 0 +$lineCount = 0 +try { + while ($reader.Read()) { + if ($reader.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } + + switch ($reader.Name) { + 'package' { + $currentModule = $reader.GetAttribute('name') + if (-not $currentModule) { $currentModule = '(unnamed)' } + if (-not $moduleMap.ContainsKey($currentModule)) { + $moduleMap[$currentModule] = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[int,int]]' + $moduleFileOrder[$currentModule] = New-Object 'System.Collections.Generic.List[string]' + $moduleOrder.Add($currentModule) + } + $currentFileMap = $moduleMap[$currentModule] + $currentFileOrder = $moduleFileOrder[$currentModule] + $currentDict = $null + } + 'class' { + $classCount++ + $fn = $reader.GetAttribute('filename') + if (-not $fn) { $fn = $reader.GetAttribute('name') } + if ($null -eq $currentFileMap) { continue } + if (-not $currentFileMap.TryGetValue($fn, [ref]$currentDict)) { + $currentDict = New-Object 'System.Collections.Generic.Dictionary[int,int]' + $currentFileMap[$fn] = $currentDict + $currentFileOrder.Add($fn) + } + } + 'line' { + if ($null -eq $currentDict) { continue } + $lineCount++ + $num = [int]$reader.GetAttribute('number') + $hits = [int]$reader.GetAttribute('hits') + $existing = 0 + if ($currentDict.TryGetValue($num, [ref]$existing)) { + if ($hits -gt $existing) { $currentDict[$num] = $hits } + } else { + $currentDict[$num] = $hits + } + } + } + } +} finally { + $reader.Close() +} + +Write-Host ("Parsed {0} module(s), {1} and {2} elements in {3:N1}s." -f $moduleOrder.Count, $classCount, $lineCount, $sw.Elapsed.TotalSeconds) +Write-Host ("Modules found: {0}" -f ($moduleOrder -join ', ')) + +$perModuleRows = New-Object System.Collections.Generic.List[object] + +foreach ($module in $moduleOrder) { + if ($wanted.Count -gt 0 -and -not ($wanted -contains (Get-NormalizedModuleName $module))) { + Write-Host "Skipping module '$module' (not in -Modules filter)." + continue + } + + $fileMap = $moduleMap[$module] + $fileOrder = $moduleFileOrder[$module] + $safeName = ($module -replace '[\\/:*?"<>|]', '_') + $moduleDir = Join-Path $OutputDir $safeName + New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null + + $perFile = New-Object System.Collections.Generic.List[object] + $overallTotal = 0 + $overallCovered = 0 + foreach ($fn in $fileOrder) { + $dict = $fileMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $overallTotal += $total + $overallCovered += $covered + $perFile.Add([pscustomobject]@{ + File = $fn + CoveredLines = $covered + TotalLines = $total + LineCoverage = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + }) + } + + $overallRate = if ($overallTotal -gt 0) { $overallCovered / $overallTotal } else { 0 } + $summary = [pscustomobject]@{ + Modules = $fileOrder.Count + TotalLines = $overallTotal + CoveredLines = $overallCovered + LineCoverage = [math]::Round(100.0 * $overallRate, 2) + InstrumentedModules = @($module) + SourceFile = (Resolve-Path $InputCobertura).Path + GeneratedAt = (Get-Date).ToString('o') + } + + $summary | ConvertTo-Json | Set-Content (Join-Path $moduleDir 'corrected-summary.json') + $summary | Export-Csv (Join-Path $moduleDir 'corrected-summary.csv') -NoTypeInformation + $perFile | Sort-Object File | Export-Csv (Join-Path $moduleDir 'corrected-per-file.csv') -NoTypeInformation + + $correctedXmlPath = Join-Path $moduleDir 'corrected.cobertura.xml' + $xmlSettings = New-Object System.Xml.XmlWriterSettings + $xmlSettings.Indent = $false + $xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false) + $writer = [System.Xml.XmlWriter]::Create($correctedXmlPath, $xmlSettings) + try { + $writer.WriteStartDocument() + $writer.WriteStartElement('coverage') + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('lines-covered', $overallCovered) + $writer.WriteAttributeString('lines-valid', $overallTotal) + $writer.WriteAttributeString('branches-covered', '0') + $writer.WriteAttributeString('branches-valid', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteAttributeString('version', '1.9') + $writer.WriteAttributeString('timestamp', [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $writer.WriteStartElement('sources') + $writer.WriteElementString('source', '.') + $writer.WriteEndElement() + $writer.WriteStartElement('packages') + $writer.WriteStartElement('package') + $writer.WriteAttributeString('name', $module) + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('classes') + foreach ($fn in $fileOrder) { + $dict = $fileMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $rate = if ($total -gt 0) { $covered / $total } else { 0 } + $writer.WriteStartElement('class') + $writer.WriteAttributeString('name', $fn) + $writer.WriteAttributeString('filename', $fn) + $writer.WriteAttributeString('line-rate', $rate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('methods') + $writer.WriteEndElement() + $writer.WriteStartElement('lines') + foreach ($num in ($dict.Keys | Sort-Object)) { + $writer.WriteStartElement('line') + $writer.WriteAttributeString('number', $num) + $writer.WriteAttributeString('hits', $dict[$num]) + $writer.WriteAttributeString('branch', 'false') + $writer.WriteEndElement() + } + $writer.WriteEndElement() # lines + $writer.WriteEndElement() # class + } + $writer.WriteEndElement() # classes + $writer.WriteEndElement() # package + $writer.WriteEndElement() # packages + $writer.WriteEndElement() # coverage + $writer.WriteEndDocument() + } finally { + $writer.Close() + } + + $perModuleRows.Add([pscustomobject]@{ + Module = $module + Files = $fileOrder.Count + TotalLines = $overallTotal + CoveredLines = $overallCovered + LineCoverage = $summary.LineCoverage + OutputFolder = $moduleDir + }) + + Write-Host ("`n=== {0} ===" -f $module) + Write-Host (" Files: {0}" -f $fileOrder.Count) + Write-Host (" Total lines: {0}" -f $overallTotal) + Write-Host (" Covered lines: {0}" -f $overallCovered) + Write-Host (" Line coverage: {0}%" -f $summary.LineCoverage) +} + +if ($perModuleRows.Count -eq 0) { + throw "No modules were emitted. Modules present in the input were: $($moduleOrder -join ', '). Check the -Modules filter." +} + +$perModuleCsv = Join-Path $OutputDir 'per-module-summary.csv' +$perModuleRows | Export-Csv $perModuleCsv -NoTypeInformation +Write-Host "`nWrote per-module summary: $perModuleCsv" diff --git a/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp b/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp index 9c16ec52c..83c3826d2 100644 --- a/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp +++ b/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp @@ -9,8 +9,6 @@ namespace winrt::TableViewAppCppPackaged::implementation { App::App() { - HighContrastAdjustment(ApplicationHighContrastAdjustment::None); - #if defined _DEBUG && !defined DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION UnhandledException([](IInspectable const&, UnhandledExceptionEventArgs const& e) { diff --git a/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp b/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp index 82c1ab28a..c550fed56 100644 --- a/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp +++ b/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp @@ -9,8 +9,6 @@ namespace winrt::TableViewAppCppUnpackaged::implementation { App::App() { - HighContrastAdjustment(ApplicationHighContrastAdjustment::None); - #if defined _DEBUG && !defined DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION UnhandledException([](IInspectable const&, UnhandledExceptionEventArgs const& e) { diff --git a/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs b/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs index 74c3941b9..9df874b95 100644 --- a/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs +++ b/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs @@ -7,7 +7,6 @@ namespace TableViewAppCsPackaged public App() { this.InitializeComponent(); - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; } protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) diff --git a/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs b/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs index ac63ac38d..ae20e1ecf 100644 --- a/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs +++ b/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs @@ -7,7 +7,6 @@ namespace TableViewAppCsUnpackaged public App() { this.InitializeComponent(); - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; } protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) diff --git a/Samples/TableViewSampleApp/App.xaml.cs b/Samples/TableViewSampleApp/App.xaml.cs index 788d4c7ac..4158fe9c8 100644 --- a/Samples/TableViewSampleApp/App.xaml.cs +++ b/Samples/TableViewSampleApp/App.xaml.cs @@ -30,7 +30,6 @@ public partial class App : Application { _commandLineArguments = commandLineArguments ?? Array.Empty(); InitializeComponent(); - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; } protected override void OnLaunched(LaunchActivatedEventArgs args) diff --git a/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml b/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml index ee6562c2f..a751f0f74 100644 --- a/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml +++ b/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml @@ -62,14 +62,14 @@ - + - + - 0.7 + 0.77 0 + @@ -119,12 +120,13 @@ - 0.7 + 0.7 0 + @@ -167,6 +169,7 @@ + diff --git a/controls/dev/Generated/TableView.properties.cpp b/controls/dev/Generated/TableView.properties.cpp index ef4bd9b53..de0913360 100644 --- a/controls/dev/Generated/TableView.properties.cpp +++ b/controls/dev/Generated/TableView.properties.cpp @@ -16,6 +16,7 @@ namespace winrt::Microsoft::UI::Xaml::Controls::Tabular GlobalDependencyProperty TableViewProperties::s_AlternatingRowBackgroundProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_CanUserResizeColumnsProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_CanUserSortColumnsProperty{ nullptr }; +GlobalDependencyProperty TableViewProperties::s_ColumnHeaderStyleProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_ColumnsProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_DensityProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_EmptyTemplateProperty{ nullptr }; @@ -74,6 +75,17 @@ void TableViewProperties::EnsureProperties() ValueHelper::BoxValueIfNecessary(true), winrt::PropertyChangedCallback(&OnCanUserSortColumnsPropertyChanged)); } + if (!s_ColumnHeaderStyleProperty) + { + s_ColumnHeaderStyleProperty = + InitializeDependencyProperty( + L"ColumnHeaderStyle", + winrt::name_of(), + winrt::name_of(), + false /* isAttached */, + ValueHelper::BoxedDefaultValue(), + winrt::PropertyChangedCallback(&OnColumnHeaderStylePropertyChanged)); + } if (!s_ColumnsProperty) { s_ColumnsProperty = @@ -213,6 +225,7 @@ void TableViewProperties::ClearProperties() s_AlternatingRowBackgroundProperty = nullptr; s_CanUserResizeColumnsProperty = nullptr; s_CanUserSortColumnsProperty = nullptr; + s_ColumnHeaderStyleProperty = nullptr; s_ColumnsProperty = nullptr; s_DensityProperty = nullptr; s_EmptyTemplateProperty = nullptr; @@ -251,6 +264,14 @@ void TableViewProperties::OnCanUserSortColumnsPropertyChanged( winrt::get_self(owner)->OnCanUserSortColumnsPropertyChanged(args); } +void TableViewProperties::OnColumnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args) +{ + auto owner = sender.as(); + winrt::get_self(owner)->OnColumnHeaderStylePropertyChanged(args); +} + void TableViewProperties::OnColumnsPropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args) @@ -370,6 +391,19 @@ bool TableViewProperties::CanUserSortColumns() return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_CanUserSortColumnsProperty)); } +void TableViewProperties::ColumnHeaderStyle(winrt::Style const& value) +{ + [[gsl::suppress(con)]] + { + static_cast(this)->SetValue(s_ColumnHeaderStyleProperty, ValueHelper::BoxValueIfNecessary(value)); + } +} + +winrt::Style TableViewProperties::ColumnHeaderStyle() +{ + return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_ColumnHeaderStyleProperty)); +} + void TableViewProperties::Columns(winrt::IVector const& value) { [[gsl::suppress(con)]] diff --git a/controls/dev/Generated/TableView.properties.h b/controls/dev/Generated/TableView.properties.h index ef3e28a8d..df3bbf4c9 100644 --- a/controls/dev/Generated/TableView.properties.h +++ b/controls/dev/Generated/TableView.properties.h @@ -18,6 +18,9 @@ public: void CanUserSortColumns(bool value); bool CanUserSortColumns(); + void ColumnHeaderStyle(winrt::Style const& value); + winrt::Style ColumnHeaderStyle(); + void Columns(winrt::IVector const& value); winrt::IVector Columns(); @@ -57,6 +60,7 @@ public: static winrt::DependencyProperty AlternatingRowBackgroundProperty() { return s_AlternatingRowBackgroundProperty; } static winrt::DependencyProperty CanUserResizeColumnsProperty() { return s_CanUserResizeColumnsProperty; } static winrt::DependencyProperty CanUserSortColumnsProperty() { return s_CanUserSortColumnsProperty; } + static winrt::DependencyProperty ColumnHeaderStyleProperty() { return s_ColumnHeaderStyleProperty; } static winrt::DependencyProperty ColumnsProperty() { return s_ColumnsProperty; } static winrt::DependencyProperty DensityProperty() { return s_DensityProperty; } static winrt::DependencyProperty EmptyTemplateProperty() { return s_EmptyTemplateProperty; } @@ -73,6 +77,7 @@ public: static GlobalDependencyProperty s_AlternatingRowBackgroundProperty; static GlobalDependencyProperty s_CanUserResizeColumnsProperty; static GlobalDependencyProperty s_CanUserSortColumnsProperty; + static GlobalDependencyProperty s_ColumnHeaderStyleProperty; static GlobalDependencyProperty s_ColumnsProperty; static GlobalDependencyProperty s_DensityProperty; static GlobalDependencyProperty s_EmptyTemplateProperty; @@ -118,6 +123,10 @@ public: winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); + static void OnColumnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args); + static void OnColumnsPropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); diff --git a/controls/dev/Generated/TableViewColumn.properties.cpp b/controls/dev/Generated/TableViewColumn.properties.cpp index b87f0ec81..98d49e174 100644 --- a/controls/dev/Generated/TableViewColumn.properties.cpp +++ b/controls/dev/Generated/TableViewColumn.properties.cpp @@ -21,6 +21,7 @@ GlobalDependencyProperty TableViewColumnProperties::s_CellEditingTemplatePropert GlobalDependencyProperty TableViewColumnProperties::s_FrozenEdgeProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderContextFlyoutProperty{ nullptr }; +GlobalDependencyProperty TableViewColumnProperties::s_HeaderStyleProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderTemplateProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderTemplateSelectorProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderToolTipProperty{ nullptr }; @@ -128,6 +129,17 @@ void TableViewColumnProperties::EnsureProperties() ValueHelper::BoxedDefaultValue(), nullptr); } + if (!s_HeaderStyleProperty) + { + s_HeaderStyleProperty = + InitializeDependencyProperty( + L"HeaderStyle", + winrt::name_of(), + winrt::name_of(), + false /* isAttached */, + ValueHelper::BoxedDefaultValue(), + winrt::PropertyChangedCallback(&OnHeaderStylePropertyChanged)); + } if (!s_HeaderTemplateProperty) { s_HeaderTemplateProperty = @@ -261,6 +273,7 @@ void TableViewColumnProperties::ClearProperties() s_FrozenEdgeProperty = nullptr; s_HeaderProperty = nullptr; s_HeaderContextFlyoutProperty = nullptr; + s_HeaderStyleProperty = nullptr; s_HeaderTemplateProperty = nullptr; s_HeaderTemplateSelectorProperty = nullptr; s_HeaderToolTipProperty = nullptr; @@ -314,6 +327,14 @@ void TableViewColumnProperties::OnHeaderPropertyChanged( winrt::get_self(owner)->OnPropertyChanged(args); } +void TableViewColumnProperties::OnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args) +{ + auto owner = sender.as(); + winrt::get_self(owner)->OnPropertyChanged(args); +} + void TableViewColumnProperties::OnHeaderTemplatePropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args) @@ -482,6 +503,19 @@ winrt::FlyoutBase TableViewColumnProperties::HeaderContextFlyout() return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_HeaderContextFlyoutProperty)); } +void TableViewColumnProperties::HeaderStyle(winrt::Style const& value) +{ + [[gsl::suppress(con)]] + { + static_cast(this)->SetValue(s_HeaderStyleProperty, ValueHelper::BoxValueIfNecessary(value)); + } +} + +winrt::Style TableViewColumnProperties::HeaderStyle() +{ + return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_HeaderStyleProperty)); +} + void TableViewColumnProperties::HeaderTemplate(winrt::DataTemplate const& value) { [[gsl::suppress(con)]] diff --git a/controls/dev/Generated/TableViewColumn.properties.h b/controls/dev/Generated/TableViewColumn.properties.h index 9aaecccbb..5a8800b81 100644 --- a/controls/dev/Generated/TableViewColumn.properties.h +++ b/controls/dev/Generated/TableViewColumn.properties.h @@ -33,6 +33,9 @@ public: void HeaderContextFlyout(winrt::FlyoutBase const& value); winrt::FlyoutBase HeaderContextFlyout(); + void HeaderStyle(winrt::Style const& value); + winrt::Style HeaderStyle(); + void HeaderTemplate(winrt::DataTemplate const& value); winrt::DataTemplate HeaderTemplate(); @@ -74,6 +77,7 @@ public: static winrt::DependencyProperty FrozenEdgeProperty() { return s_FrozenEdgeProperty; } static winrt::DependencyProperty HeaderProperty() { return s_HeaderProperty; } static winrt::DependencyProperty HeaderContextFlyoutProperty() { return s_HeaderContextFlyoutProperty; } + static winrt::DependencyProperty HeaderStyleProperty() { return s_HeaderStyleProperty; } static winrt::DependencyProperty HeaderTemplateProperty() { return s_HeaderTemplateProperty; } static winrt::DependencyProperty HeaderTemplateSelectorProperty() { return s_HeaderTemplateSelectorProperty; } static winrt::DependencyProperty HeaderToolTipProperty() { return s_HeaderToolTipProperty; } @@ -94,6 +98,7 @@ public: static GlobalDependencyProperty s_FrozenEdgeProperty; static GlobalDependencyProperty s_HeaderProperty; static GlobalDependencyProperty s_HeaderContextFlyoutProperty; + static GlobalDependencyProperty s_HeaderStyleProperty; static GlobalDependencyProperty s_HeaderTemplateProperty; static GlobalDependencyProperty s_HeaderTemplateSelectorProperty; static GlobalDependencyProperty s_HeaderToolTipProperty; @@ -129,6 +134,10 @@ public: winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); + static void OnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args); + static void OnHeaderTemplatePropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); diff --git a/controls/dev/NavigationView/NavigationView.cpp b/controls/dev/NavigationView/NavigationView.cpp index 4d9fdbaf0..521909f15 100644 --- a/controls/dev/NavigationView/NavigationView.cpp +++ b/controls/dev/NavigationView/NavigationView.cpp @@ -696,6 +696,7 @@ void NavigationView::OnApplyTemplate() m_shadowCasterEaseOutStoryboard.set(GetTemplateChildT(c_shadowCasterEaseOutStoryboard, controlProtected)); m_appliedTemplate = true; + m_templateAppliedOnce = true; // Do initial setup UpdatePaneDisplayMode(); @@ -4224,7 +4225,12 @@ void NavigationView::OnPropertyChanged(const winrt::DependencyPropertyChangedEve { // m_wasForceClosed is set to true because ToggleButton is clicked and Pane is closed. // When PaneDisplayMode is changed, reset the force flag to make the Pane can be opened automatically again. - m_wasForceClosed = false; + // Before the template is first applied, the flag can only have come from the app setting IsPaneOpen=false + // (e.g. in XAML ahead of PaneDisplayMode), so keep it to honor that initial state. Re-templating still resets it. + if (m_templateAppliedOnce) + { + m_wasForceClosed = false; + } UpdatePaneToggleButtonVisibility(); UpdatePaneDisplayMode(auto_unbox(args.OldValue()), auto_unbox(args.NewValue())); diff --git a/controls/dev/NavigationView/NavigationView.h b/controls/dev/NavigationView/NavigationView.h index d0f29b196..43440ef71 100644 --- a/controls/dev/NavigationView/NavigationView.h +++ b/controls/dev/NavigationView/NavigationView.h @@ -482,6 +482,8 @@ private: NavigationRecommendedTransitionDirection m_pendingSelectionChangedDirection{ NavigationRecommendedTransitionDirection::Default }; bool m_appliedTemplate{ false }; + // Unlike m_appliedTemplate, this is never reset, so it tells first-time setup apart from re-templating. + bool m_templateAppliedOnce{ false }; // Identifies whenever a call is the result of OnApplyTemplate bool m_fromOnApplyTemplate{ false }; diff --git a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs index 077cc1848..8ce9f8c96 100644 --- a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs +++ b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs @@ -543,6 +543,144 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + [TestProperty("Description", "Verifies IsPaneOpen=false set before PaneDisplayMode=Left is respected on launch (GitHub #7609).")] + public void VerifyIsPaneOpenFalseRespectedWhenSetBeforePaneDisplayModeLeft() + { + Log.Comment("Code: IsPaneOpen=false, then PaneDisplayMode=Left"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + Log.Comment("XAML: IsPaneOpen='False' PaneDisplayMode='Left'"); + VerifyLaunchState(() => + { + return (NavigationView)XamlReader.Load(@" + + + + + "); + }, expectedIsPaneOpen: false); + + Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=LeftCompact, then PaneDisplayMode=Left"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + Log.Comment("Code: IsPaneOpen not set, PaneDisplayMode=Left (default open pane is unchanged)"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: true); + + Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=Left, then IsPaneOpen=true before load (open wins)"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + navView.IsPaneOpen = true; + return navView; + }, expectedIsPaneOpen: true); + + Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=LeftCompact, then Auto launching in Expanded"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Auto; + navView.ExpandedModeThresholdWidth = 600.0; + navView.CompactModeThresholdWidth = 400.0; + navView.Width = 610.0; + return navView; + }, expectedIsPaneOpen: false); + + Log.Comment("Runtime: after launching closed, LeftCompact -> Left opens the pane (post-template PaneDisplayMode change still clears the force-close flag)"); + var launchedNavViewCompact = VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + RunOnUIThread.Execute(() => launchedNavViewCompact.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => launchedNavViewCompact.PaneDisplayMode = NavigationViewPaneDisplayMode.Left); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => + { + Verify.IsTrue(launchedNavViewCompact.IsPaneOpen, "NavigationView.IsPaneOpen after runtime LeftCompact -> Left"); + var splitView = VisualTreeUtils.FindVisualChildByName(launchedNavViewCompact, "RootSplitView") as SplitView; + Verify.IsTrue(splitView.IsPaneOpen, "RootSplitView.IsPaneOpen after runtime LeftCompact -> Left"); + }); + + Log.Comment("Runtime: after launching closed, LeftMinimal -> Left opens the pane (by-design explicit OpenPane in UpdatePaneDisplayMode)"); + var launchedNavView = VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + RunOnUIThread.Execute(() => launchedNavView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftMinimal); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => launchedNavView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => + { + Verify.IsTrue(launchedNavView.IsPaneOpen, "Pane should open on a runtime LeftMinimal -> Left switch"); + }); + + NavigationView VerifyLaunchState(Func createNavView, bool expectedIsPaneOpen) + { + NavigationView navView = null; + RunOnUIThread.Execute(() => + { + navView = createNavView(); + Content = navView; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(NavigationViewDisplayMode.Expanded, navView.DisplayMode); + Verify.AreEqual(expectedIsPaneOpen, navView.IsPaneOpen, "NavigationView.IsPaneOpen"); + + var splitView = VisualTreeUtils.FindVisualChildByName(navView, "RootSplitView") as SplitView; + Verify.IsNotNull(splitView); + Verify.AreEqual(expectedIsPaneOpen, splitView.IsPaneOpen, "RootSplitView.IsPaneOpen"); + }); + + return navView; + } + } + [TestMethod] public void VerifyDefaultsAndBasicSetting() { diff --git a/controls/dev/TableView/APITests/TableViewSource_APITests.cs b/controls/dev/TableView/APITests/TableViewSource_APITests.cs index f7f6ba0a9..b31bc7bc3 100644 --- a/controls/dev/TableView/APITests/TableViewSource_APITests.cs +++ b/controls/dev/TableView/APITests/TableViewSource_APITests.cs @@ -782,6 +782,66 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies Sort(keySelector, None) removes the axis keyed by that delegate instance, a None for an unknown delegate leaves the sort unchanged, and ClearSort removes every key-selector axis.")] + public void VerifyKeySelectorSortNoneFollowsDelegateIdentity() + { + TableView tableView = null; + TableViewSource source = null; + List items = null; + TableViewKeySelector byLength = o => Person(o).Name.Length; + var byLengthAscending = new List { "Mei", "Asha", "Rafa", "Ines", "Owen", "Diego" }; + + RunOnUIThread.Execute(() => + { + items = MakeShapedPeople(); + source = TableViewSource.From(items).Sort(byLength, SortDirection.Ascending); + tableView = CreateShapedTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, byLengthAscending, "baseline: sorted by name length"); + TableViewKeySelector sameKeyOtherInstance = o => Person(o).Name.Length; + source.Sort(sameKeyOtherInstance, SortDirection.None); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, byLengthAscending, "None with a delegate that keys no axis must leave the sort unchanged"); + source.Sort(byLength, SortDirection.None); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, items.Select(p => p.Name).ToList(), "None with the same delegate instance must remove its axis"); + source.Sort(byLength, SortDirection.Ascending).Sort(o => Person(o).Role, SortDirection.Descending); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var labels = GetProjectedLabels(tableView, includeCount: true); + Verify.IsTrue(labels.First() == "Mei" && labels.Last() == "Diego", "Precondition: two key-selector axes, name length primary."); + source.ClearSort(); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, items.Select(p => p.Name).ToList(), "ClearSort must remove every key-selector axis"); + }); + } + [TestMethod] [TestProperty("Description", "Verifies SortDirection.None removes that axis rather than seeding a stage.")] public void VerifySortDirectionNoneRemovesThatAxis() diff --git a/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs b/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs index 665c896a7..27e4c2a84 100644 --- a/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs @@ -10,6 +10,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Tabular; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; using MUXControlsTestApp.Utilities; using System; using System.Collections.Generic; @@ -632,6 +633,48 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies the public cell peer constructor rejects arguments that cannot describe a cell, and accepts ones that can.")] + public void VerifyCellPeerConstructorValidatesArguments() + { + TableView tableView = null; + TableView otherTable = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateAutomationTable(); + otherTable = CreateAutomationTable(); + var host = new StackPanel(); + host.Children.Add(tableView); + host.Children.Add(otherTable); + LoadContent(host); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var row = GetRealizedRows(tableView).First(); + var cell = GetRowCell(row, 0); + var column = tableView.Columns[0]; + int count = tableView.Columns.Count; + + Verify.Throws(() => new TableViewCellAutomationPeer(null, row, column, 0), "A null cell must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, null, column, 0), "A null row must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, null, 0), "A null column must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, column, -1), "A negative column index must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, column, count), "A column index past the table's columns must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, otherTable.Columns[0], 0), "A column from another table must be rejected."); + + var peer = new TableViewCellAutomationPeer(cell, row, column, count - 1); + Verify.IsNotNull(peer, "In-range arguments must construct a peer."); + + // A row no TableView owns has no column set to check against, so only the index sign is enforced. + Verify.IsNotNull(new TableViewCellAutomationPeer(new Grid(), new TableViewRow(), new TableViewTextColumn(), 3), + "An unowned row must accept any non-negative index."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies a cell peer whose row is gone throws ElementNotAvailable from GridItem.Row instead of reporting -1.")] public void VerifyStaleCellPeerGridItemRowThrowsElementNotAvailable() @@ -1627,6 +1670,411 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies a header disabled by its HeaderStyle reports IsEnabled=false on its realized and table-owned peers and refuses Invoke.")] + public void VerifyHeaderDisabledByStyleIsNotEnabledOrInvokable() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateAutomationTable(); + tableView.CanUserSortColumns = true; + tableView.Columns[0].CanSort = true; + var disabledStyle = new Style(typeof(TableViewColumnHeader)); + disabledStyle.Setters.Add(new Setter(Control.IsEnabledProperty, false)); + tableView.Columns[0].HeaderStyle = disabledStyle; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var header = GetHeaderCell(tableView, 0) as Control; + Verify.IsNotNull(header, "Precondition: the header cell is a Control."); + Verify.IsFalse(header.IsEnabled, "Precondition: the HeaderStyle disables the header."); + + var peers = new (AutomationPeer Peer, string Kind)[] + { + (FrameworkElementAutomationPeer.CreatePeerForElement(header), "realized"), + (new TableViewColumnHeaderAutomationPeer(tableView, column), "table-owned"), + }; + foreach (var (peer, kind) in peers) + { + Verify.IsFalse(peer.IsEnabled(), $"The {kind} peer of a disabled header must report IsEnabled=false."); + var invoke = peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider; + Verify.IsNotNull(invoke, $"Precondition: the {kind} peer offers Invoke for a sortable column."); + VerifyFailsWithHResult(UIA_E_ELEMENTNOTENABLED, () => invoke.Invoke(), + $"Invoke on the {kind} peer of a disabled header must fail."); + } + + Verify.AreEqual(SortDirection.None, column.SortDirection, "A disabled header must not sort through UIA."); + }); + } + + private const int UIA_E_ELEMENTNOTENABLED = unchecked((int)0x80040200); + private const int UIA_E_INVALIDOPERATION = unchecked((int)0x80131509); + private const int E_INVALIDARG = unchecked((int)0x80070057); + + // By HRESULT rather than exception type: the projected type for a UIA_E_* code is not a contract. + private static void VerifyFailsWithHResult(int expectedHResult, Action action, string message) + { + try + { + action(); + } + catch (Exception e) + { + Verify.AreEqual(expectedHResult, e.HResult, $"{message} ({e.GetType().Name})"); + return; + } + + Verify.Fail($"{message} The call did not fail."); + } + + [TestMethod] + [TestProperty("Description", "Verifies the Transform pattern is offered only for columns the user may resize, and never moves or rotates.")] + public void VerifyTransformPatternTracksColumnResizability() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + tableView.Columns[1].CanResize = false; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + // UIA requires Transform on a HeaderItem the user can resize. + var transform = new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + Verify.IsTrue(transform.CanResize, "A resizable column must report CanResize."); + Verify.IsFalse(transform.CanMove, "A column header cannot be moved through Transform."); + Verify.IsFalse(transform.CanRotate, "A column header cannot be rotated."); + + Verify.IsNull(new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[1]).GetPattern(PatternInterface.Transform), + "Transform must be withheld for a column whose CanResize is false."); + + tableView.CanUserResizeColumns = false; + Verify.IsNull(new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]).GetPattern(PatternInterface.Transform), + "Transform must be withheld while CanUserResizeColumns is false."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a pinned column keeps the Transform pattern but reports CanResize=false and rejects Resize.")] + public void VerifyTransformCanResizeIsFalseForPinnedColumns() + { + TableView starTable = null; + TableView pixelTable = null; + + RunOnUIThread.Execute(() => + { + starTable = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(1.0, GridUnitType.Star)), + ("Second", new GridLength(1.0, GridUnitType.Star))); + LoadContent(starTable); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + // Same rule as gripper visibility: the last Star column has no later Star column to trade with. + var first = new TableViewColumnHeaderAutomationPeer(starTable, starTable.Columns[0]) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + var last = new TableViewColumnHeaderAutomationPeer(starTable, starTable.Columns[1]) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(first, "The first Star column opts in to resizing, so Transform must be offered."); + Verify.IsNotNull(last, "The pinned Star column still opts in to resizing, so Transform must be offered."); + Verify.IsTrue(first.CanResize, "A Star column with a later Star donor can be resized."); + Verify.IsFalse(last.CanResize, "The last Star column is pinned, so CanResize must be false."); + + var widthBefore = starTable.Columns[1].Width; + VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => last.Resize(80.0, 0.0), + "Resize on a pinned column must fail."); + Verify.AreEqual(widthBefore, starTable.Columns[1].Width, "A rejected Resize must not touch Width."); + + pixelTable = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(120.0, GridUnitType.Pixel))); + LoadContent(pixelTable); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var fixedColumn = pixelTable.Columns[0]; + fixedColumn.MinWidth = 120.0; + fixedColumn.MaxWidth = 120.0; + var fixedTransform = new TableViewColumnHeaderAutomationPeer(pixelTable, fixedColumn) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(fixedTransform, "A fixed-width column that opts in to resizing still offers Transform."); + Verify.IsFalse(fixedTransform.CanResize, "MinWidth == MaxWidth pins the column, so CanResize must be false."); + + fixedColumn.MaxWidth = double.PositiveInfinity; + Verify.IsTrue(fixedTransform.CanResize, "Lifting MaxWidth must make the column resizable again."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize sets the column width through the resize path and clamps to MinWidth/MaxWidth.")] + public void VerifyTransformResizeSetsWidthAndClampsToBounds() + { + TableView tableView = null; + ITransformProvider transform = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + tableView.Columns[0].MinWidth = 80.0; + tableView.Columns[0].MaxWidth = 260.0; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + // Height is ignored: a column only resizes along its width. UIA widths are physical pixels. + transform.Resize(200.0 * tableView.XamlRoot.RasterizationScale, 999.0); + Verify.AreEqual(GridUnitType.Pixel, column.Width.GridUnitType, "A resize writes a pixel width."); + Verify.AreEqual(200.0, column.Width.Value, "Resize must set the requested width."); + Content.UpdateLayout(); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + Verify.AreEqual(200.0, column.ActualWidth, "ActualWidth must follow the width Resize wrote."); + Verify.AreEqual(150.0, tableView.Columns[1].Width.Value, "Resizing one column must not change another fixed column."); + + transform.Resize(1000.0 * tableView.XamlRoot.RasterizationScale, 0.0); + Verify.AreEqual(260.0, column.Width.Value, "Resize must clamp to MaxWidth."); + Content.UpdateLayout(); + + transform.Resize(10.0 * tableView.XamlRoot.RasterizationScale, 0.0); + Verify.AreEqual(80.0, column.Width.Value, "Resize must clamp to MinWidth."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize reads its width in physical pixels, the units UIA reports bounds in.")] + public void VerifyTransformResizeWidthIsPhysicalPixels() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + var scale = tableView.XamlRoot.RasterizationScale; + Log.Comment($"RasterizationScale = {scale}"); + if (scale == 1.0) + { + // Not settable from a test, and at 1.0 pixels equal DIPs, so the conversion is unobservable. + Log.Warning("SKIPPED (reported as passed): the physical-pixel conversion cannot be observed at RasterizationScale 1.0; run on a scaled display to cover it."); + return; + } + + transform.Resize(180.0 * scale, 0.0); + Verify.IsTrue(Math.Abs(column.Width.Value - 180.0) < 0.01, + $"Resize must convert physical pixels to DIPs; expected 180, got {column.Width.Value}."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize converts physical pixels through a scale applied above the table, not only the display scale.")] + public void VerifyTransformResizeAccountsForAncestorScale() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + var scaledHost = new Grid { RenderTransform = new ScaleTransform { ScaleX = 2.0, ScaleY = 2.0 } }; + scaledHost.Children.Add(tableView); + LoadContent(scaledHost); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + // On screen each DIP of the column spans this many physical pixels. + var pixelsPerDip = tableView.XamlRoot.RasterizationScale * 2.0; + transform.Resize(170.0 * pixelsPerDip, 0.0); + Verify.IsTrue(Math.Abs(column.Width.Value - 170.0) < 0.01, + $"Resize must convert physical pixels through the ancestor scale; expected 170, got {column.Width.Value}."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize applies a width change smaller than the pointer drag's jitter threshold.")] + public void VerifyTransformResizeAppliesSmallWidthChange() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + Verify.AreEqual(150.0, column.ActualWidth, "Precondition: the column starts at 150."); + + // One physical pixel at 300% scale; the pointer drag treats under half a DIP as jitter. + transform.Resize(150.3 * tableView.XamlRoot.RasterizationScale, 0.0); + Verify.IsTrue(Math.Abs(column.Width.Value - 150.3) < 0.01, + $"An absolute UIA width must be applied however small the change; expected 150.3, got {column.Width.Value}."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a Transform.Resize below the drag jitter threshold writes Width at most once, lands exactly next to a bound, and leaves an Auto column Auto when it asks for its current width.")] + [TestProperty("data:Case", "{NearMin, NearMax, AtMin, AutoNoop}")] + public void VerifyTransformResizeSmallWidthChangeWritesOnce() + { + var testCase = ((string)TestContext.DataRow["Case"]).Trim(); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", testCase == "AutoNoop" ? GridLength.Auto : new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + var first = tableView.Columns[0]; + switch (testCase) + { + case "NearMin": first.MinWidth = 100.0; first.Width = new GridLength(100.4, GridUnitType.Pixel); break; + case "NearMax": first.MaxWidth = 200.0; first.Width = new GridLength(199.6, GridUnitType.Pixel); break; + case "AtMin": first.MinWidth = 100.0; first.Width = new GridLength(100.0, GridUnitType.Pixel); break; + } + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + double target = column.ActualWidth + 0.0001; + double expected = column.ActualWidth; + switch (testCase) + { + case "NearMin": target = expected = 100.1; break; + case "NearMax": target = expected = 199.9; break; + case "AtMin": target = 99.7; expected = 100.0; break; + } + int expectedWrites = testCase == "NearMin" || testCase == "NearMax" ? 1 : 0; + + int widthWrites = 0; + var widths = new List(); + column.RegisterPropertyChangedCallback(TableViewColumn.WidthProperty, (s, dp) => + { + widthWrites++; + widths.Add(column.Width.ToString()); + }); + + transform.Resize(target * tableView.XamlRoot.RasterizationScale, 0.0); + Log.Comment($"{testCase}: Width writes [{string.Join(", ", widths)}], final {column.Width}."); + + Verify.AreEqual(expectedWrites, widthWrites, $"{testCase}: Width must be written {expectedWrites} time(s)."); + if (testCase == "AutoNoop") + { + Verify.AreEqual(GridUnitType.Auto, column.Width.GridUnitType, "A request for the current width must leave an Auto column Auto."); + } + else + { + Verify.IsTrue(Math.Abs(column.Width.Value - expected) < 0.01, $"{testCase}: expected {expected}, got {column.Width.Value}."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Move and Rotate fail, and Resize rejects a non-finite or negative width.")] + public void VerifyTransformMoveRotateAndInvalidResizeFail() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel))); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => transform.Move(10.0, 10.0), "Move must fail: CanMove is false."); + VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => transform.Rotate(90.0), "Rotate must fail: CanRotate is false."); + VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(double.NaN, 0.0), "Resize must reject NaN."); + VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(double.PositiveInfinity, 0.0), "Resize must reject infinity."); + VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(-1.0, 0.0), "Resize must reject a negative width."); + + Verify.AreEqual(150.0, column.Width.Value, "A failed Transform call must leave the width untouched."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies a column header peer is a childless HeaderItem.")] public void VerifyColumnHeaderPeerIsALeafHeaderItem() diff --git a/controls/dev/TableView/APITests/TableView_Columns_APITests.cs b/controls/dev/TableView/APITests/TableView_Columns_APITests.cs index 0e6ea0fa8..c629657e5 100644 --- a/controls/dev/TableView/APITests/TableView_Columns_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Columns_APITests.cs @@ -25,8 +25,8 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests // Category 3.1 of the TableView test plan: the Columns collection. // // These tests assert against two structures the control builds from Columns: - // - PART_HeaderHost, whose children are header cell Grids, each tagged with its column - // (TableView.cpp:1610, headerCell.Tag(column)). + // - PART_HeaderHost, whose children are TableViewColumnHeader controls, each tagged with its column + // (TableView::RebuildHeaders, header.Tag(column)). // - each realized TableViewRow's PART_CellsHost, whose children are Grid cell wrappers, // each tagged with its column (TableViewRow.cpp:762, cellWrapper.Tag(column)). // The Tag is the supported way to map a rendered element back to the column that produced it, @@ -416,6 +416,118 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies a header rebuild leaves focus where the app moved it while the focused header's Content was being cleared.")] + public void VerifyHeaderRebuildKeepsAppFocusMoveFromFocusedHeader() + { + TableView tableView = null; + TextBox external = null; + bool moved = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(headers: new[] { "Name", "Role", "City" }); + external = new TextBox(); + var host = new StackPanel(); + host.Children.Add(external); + host.Children.Add(tableView); + LoadContent(host); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = GetHeaderCell(tableView, 1) as ContentControl; + Verify.IsNotNull(header, "Precondition: the header cell is a ContentControl."); + Verify.IsTrue(header.Focus(FocusState.Keyboard), "Precondition: header 1 takes keyboard focus."); + header.RegisterPropertyChangedCallback(ContentControl.ContentProperty, (sender, property) => + { + if (!moved && ((ContentControl)sender).Content == null) + { + moved = true; + external.Focus(FocusState.Programmatic); + } + }); + tableView.CanUserSortColumns = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(moved, "Precondition: the rebuild cleared the focused header's Content."); + Verify.IsTrue(ReferenceEquals(external, FocusManager.GetFocusedElement(tableView.XamlRoot)), + "The rebuild must not pull focus back from where the app moved it."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header rebuild leaves focus where the app moved it, out of the table, while the header's focused Button content is being released.")] + [TestProperty("data:Move", "{ContentCallback, LosingFocusRedirect}")] + public void VerifyHeaderRebuildKeepsAppFocusMoveFromFocusedHeaderContent() + { + var move = ((string)TestContext.DataRow["Move"]).Trim(); + TableView tableView = null; + TextBox external = null; + Button headerButton = null; + bool moved = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(headers: new[] { "Name", "Role", "City" }); + headerButton = new Button { Content = "Role" }; + tableView.Columns[1].Header = headerButton; + external = new TextBox(); + var host = new StackPanel(); + host.Children.Add(external); + host.Children.Add(tableView); + LoadContent(host); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = GetHeaderCell(tableView, 1) as ContentControl; + Verify.IsNotNull(header, "Precondition: the header cell is a ContentControl."); + Verify.IsTrue(ReferenceEquals(headerButton, header.Content), "Precondition: the header hosts the app's Button."); + Verify.IsTrue(headerButton.Focus(FocusState.Keyboard), "Precondition: the header's Button takes keyboard focus."); + if (move == "ContentCallback") + { + header.RegisterPropertyChangedCallback(ContentControl.ContentProperty, (sender, property) => + { + if (!moved && ((ContentControl)sender).Content == null) + { + moved = external.Focus(FocusState.Programmatic); + } + }); + } + else + { + // The app sends focus out of the table when its header content gives it up. + headerButton.LosingFocus += (sender, args) => + { + if (!moved) + { + moved = args.TrySetNewFocusedElement(external); + } + }; + } + tableView.CanUserSortColumns = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(moved, "Precondition: the app moved focus during the rebuild."); + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot); + Log.Comment($"Focused after rebuild: {(focused as FrameworkElement)?.GetType().Name}"); + Verify.IsTrue(ReferenceEquals(external, focused), + "The rebuild must not pull focus back from where the app moved it."); + }); + } [TestMethod] [TestProperty("Description", "Verifies a focused header keeps focus across the header rebuild a theme change performs.")] public void VerifyHeaderFocusSurvivesThemeChange() diff --git a/controls/dev/TableView/APITests/TableView_Editing_APITests.cs b/controls/dev/TableView/APITests/TableView_Editing_APITests.cs index 3498d827b..d930b1a21 100644 --- a/controls/dev/TableView/APITests/TableView_Editing_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Editing_APITests.cs @@ -9,6 +9,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Controls.Tabular; using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Markup; using MUXControlsTestApp.Utilities; using System; @@ -548,6 +549,208 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.AreEqual("Asha", items[0].Name, "The cancel must leave the pre-edit value in place."); }); } + + [TestMethod] + [TestProperty("Description", "Verifies a CanSort = false clear queued behind a sort on another column, both requested from CellEditEnding, does not clear that newer sort.")] + public void VerifyQueuedCanSortClearDoesNotClearNewerSortRequestedDuringEditEnding() + { + TableView tableView = null; + List items = null; + bool requested = false; + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateEditableTable(items); + tableView.IsReadOnly = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the Name column sorts."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var name = tableView.Columns[0]; + var role = tableView.Columns[1]; + tableView.CellEditEnding += (s, e) => + { + if (requested) + { + return; + } + requested = true; + tableView.SortByColumn(role, SortDirection.Ascending); + name.CanSort = false; + }; + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha K"); + Verify.IsNull(error, "The commit must succeed."); + Verify.IsTrue(requested, "Precondition: CellEditEnding requested both changes."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out column must not stay sorted."); + Verify.AreEqual(SortDirection.Ascending, tableView.Columns[1].SortDirection, + "The stale opt-out clear must not remove the newer sort on another column."); + var roles = GetProjectedItems(tableView).Select(item => ((EditablePerson)item).Role).ToList(); + Verify.AreEqual("Architect,Designer,Engineer", string.Join(",", roles), "The rows must stay sorted by Role."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a sort opt-out made from a BeginningEdit handler, cancelled or not, still clears the opted-out sort.")] + [TestProperty("data:Change", "{CanSortCancel, CanSortContinue, CanUserSortColumnsCancel}")] + public void VerifySortOptOutDuringBeginningEditClearsSort() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + List items = null; + bool requested = false; + int clearedCount = 0; + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateEditableTable(items); + tableView.IsReadOnly = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the Name column sorts."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var name = tableView.Columns[0]; + tableView.Sorted += (s, e) => + { + if (e.Direction == SortDirection.None) + { + clearedCount++; + } + }; + tableView.BeginningEdit += (s, e) => + { + if (requested) + { + return; + } + requested = true; + if (change == "CanUserSortColumnsCancel") + { + tableView.CanUserSortColumns = false; + } + else + { + name.CanSort = false; + } + e.Cancel = change != "CanSortContinue"; + }; + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 1), "Pilot"); + Log.Comment($"SetValue: {error?.Message ?? "succeeded"}"); + Verify.IsTrue(requested, "Precondition: BeginningEdit opted the sort out."); + if (change == "CanSortContinue") + { + Verify.IsNull(error, "An edit BeginningEdit let continue must accept the value."); + Verify.AreEqual("Pilot", items[0].Role, "The value must be committed."); + Verify.IsFalse(tableView.IsEditing, "The committed edit must be closed."); + } + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, $"{change}: the opted-out sort must be cleared."); + Verify.AreEqual(1, clearedCount, $"{change}: the clear must be reported once."); + var names = GetProjectedItems(tableView).Select(item => ((EditablePerson)item).Name).ToList(); + Verify.AreEqual("Asha,Diego,Mei", string.Join(",", names), $"{change}: the rows must return to source order."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a sort opt-out made from a BeginningEdit handler that lets the edit open keeps the edit open, and clears the sort when the edit closes.")] + public void VerifySortOptOutDuringBeginningEditKeepsTheEditOpen() + { + TableView tableView = null; + List items = null; + bool requested = false; + bool vetoClose = true; + int clearedCount = 0; + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateEditableTable(items); + tableView.IsReadOnly = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the Name column sorts."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var name = tableView.Columns[0]; + tableView.Sorted += (s, e) => + { + if (e.Direction == SortDirection.None) + { + clearedCount++; + } + }; + tableView.BeginningEdit += (s, e) => + { + if (!requested) + { + requested = true; + name.CanSort = false; + } + }; + // Holds the edit open after SetValue so its state can be observed. + tableView.CellEditEnding += (s, e) => e.Cancel = vetoClose; + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 1), "Pilot"); + Log.Comment($"SetValue: {error?.Message ?? "succeeded"}"); + Verify.IsTrue(requested, "Precondition: BeginningEdit opted the sort out."); + Verify.IsNotNull(error, "Precondition: the vetoed commit is reported."); + Verify.AreEqual(unchecked((int)0x80004005), error.HResult, "The edit must open and only the vetoed commit fail."); + Verify.IsTrue(tableView.IsEditing, "The opened edit must stay open."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.IsEditing, "The deferred sort clear must not close the open edit."); + Verify.AreEqual(0, clearedCount, "The sort clear waits for the edit to close."); + + vetoClose = false; + Verify.IsTrue(tableView.CommitEdit(), "The edit commits."); + Verify.AreEqual("Pilot", items[0].Role, "The value must be committed."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out sort must be cleared once the edit closes."); + Verify.AreEqual(1, clearedCount, "The clear must be reported once."); + }); + } } // Category 10.5 of the TableView test plan: validation, teardown and reentrancy. @@ -1632,6 +1835,95 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsFalse(tableView.IsEditing, "And must close."); }); } + + [TestMethod] + [TestProperty("Description", "Verifies a BeginEdit whose editor creation throws rolls back: no edit is left open, the display cell and focus are restored, and a later edit works.")] + public void VerifyBeginEditThatThrowsRollsBackAndAllowsALaterEdit() + { + TableView tableView = null; + List items = null; + var converter = new ArmableThrowingConverter(); + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateTableViewShell(items, 500, 300); + tableView.IsReadOnly = false; + + // An explicit Source makes the editor's binding evaluate inside the editor factory, so + // an armed converter throws out of BeginEdit itself rather than into a swallowed refresh. + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Name", + Binding = new Binding { Path = new PropertyPath("Name"), Source = items[0], Converter = converter, Mode = BindingMode.OneWay }, + }); + tableView.Columns.Add(MakeTextColumn("Role")); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + Grid cell = null; + UIElement displayContent = null; + RunOnUIThread.Execute(() => + { + cell = GetRowCell(GetRowForItem(tableView, items[0]), 0); + displayContent = GetCellContent(cell); + GetCellPeer(tableView, items[0], columnIndex: 0).SetFocus(); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(ReferenceEquals(cell, FocusManager.GetFocusedElement(tableView.XamlRoot)), + "Precondition: the cell has focus before the edit is attempted."); + + converter.Armed = true; + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha Kapoor"); + converter.Armed = false; + + Verify.IsTrue(error != null, "The throwing editor factory must surface as a failed edit."); + Verify.IsFalse(tableView.IsEditing, "A BeginEdit that threw must not leave an edit open."); + Verify.IsTrue(ReferenceEquals(displayContent, GetCellContent(cell)), "The cell must show its display content again."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(ReferenceEquals(cell, FocusManager.GetFocusedElement(tableView.XamlRoot)), + "Focus must be back on the cell after the rollback."); + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha Kapoor"); + Verify.IsTrue(error == null, $"A later edit on the same cell must start and commit. Instead: {error?.Message}"); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual("Asha Kapoor", items[0].Name, "The later edit must commit."); + Verify.IsFalse(tableView.IsEditing, "And must close."); + }); + } + } + + // Throws from Convert only while armed, so the display cells build normally. + internal sealed class ArmableThrowingConverter : IValueConverter + { + public bool Armed { get; set; } + + public object Convert(object value, Type targetType, object parameter, string language) + { + if (Armed) + { + throw new InvalidOperationException("Deliberate editor-binding failure."); + } + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => value; } internal static class TableViewEditingTestHelpers diff --git a/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs b/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs index 9503162c9..89007d87d 100644 --- a/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs @@ -12,7 +12,9 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; using System.Runtime.CompilerServices; +using Windows.Foundation; +using WEX.Logging.Interop; using WEX.TestExecution; using WEX.TestExecution.Markup; @@ -1294,6 +1296,32 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies a grouped root row is indented by the group header's expander gutter plus its spacing.")] + public void VerifyGroupedRootIndentMatchesGroupHeaderGutterAndSpacing() + { + TableViewSource source = null; + RunOnUIThread.Execute(() => source = Tree(MakeTree()).GroupBy(ByDept)); + var tableView = Place(source); + + RunOnUIThread.Execute(() => + { + var header = GetProjectedElements(tableView).OfType().First(); + var gutter = header.FindVisualChildByName("PART_ExpanderGutter") as FrameworkElement; + var gutterGrid = gutter == null ? null : Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(gutter) as Grid; + Verify.IsNotNull(gutterGrid, "The group header should host PART_ExpanderGutter in a Grid."); + double expected = gutter.ActualWidth + gutterGrid.ColumnSpacing; + + var row = FindTreeRow(tableView, "Ada"); + double rowX = row.TransformToVisual(tableView).TransformPoint(new Point(0, 0)).X; + double chevronX = GetChevron(tableView, "Ada").TransformToVisual(tableView).TransformPoint(new Point(0, 0)).X; + Log.Comment($"Root indent={chevronX - rowX}, gutter + TableViewGroupExpanderSpacing={expected}, header Padding.Left={header.Padding.Left}"); + Verify.AreEqual(24.0 + 8.0, expected, "Default gutter (TableViewGroupExpanderSize) plus TableViewGroupExpanderSpacing."); + Verify.IsLessThan(Math.Abs(chevronX - rowX - expected), 0.5, + "A grouped root row's indent must equal the group header's gutter plus TableViewGroupExpanderSpacing."); + }); + } + #endregion #region Helpers diff --git a/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs b/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs index 6d11dc4fc..e8c6efbbe 100644 --- a/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs @@ -63,6 +63,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests resources["TabularSurfaceRowBackgroundBrush"] = new SolidColorBrush(Microsoft.UI.Colors.White); resources["TabularSurfaceRowForegroundBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Black); resources["TabularSurfaceGridLineBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Gray); + resources["TabularSurfaceOuterBorderBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Gray); try { diff --git a/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs b/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs index aff8d3463..079c27a28 100644 --- a/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Tabular; +using Microsoft.UI.Xaml.Media; using MUXControlsTestApp.Utilities; using System.Collections.Generic; using System.Linq; @@ -257,6 +258,220 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsTrue(checkedAny, "Precondition: at least one realized row must be locatable in the projection."); }); } + + [TestMethod] + [TestProperty("Description", "Verifies a recycled row whose row peer exists but whose cells have no peers keeps its cell elements and shows the new item.")] + public void VerifyRecycledRowWithoutCellPeersReusesItsCells() + { + TableView tableView = null; + List items = null; + var cellsBefore = new Dictionary>(); + var contentBefore = new Dictionary>(); + + RunOnUIThread.Execute(() => + { + items = MakeManyItems(RecyclingSourceCount); + tableView = CreateScrollableTable(items); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + foreach (var row in RealizedProjectedRows(tableView)) + { + // Cells are only enumerated, never asked for peers (FromElement would create one). + GetRowPeer(row); + cellsBefore[row] = GetCellsHost(row).Children.ToList(); + contentBefore[row] = cellsBefore[row].Select(cell => (object)GetCellContent((Grid)cell)).ToList(); + } + + Verify.IsGreaterThan(cellsBefore.Count, 0, "Precondition: rows must be realized before scrolling."); + }); + + ScrollToEnd(tableView); + + RunOnUIThread.Execute(() => + { + var recycledCount = 0; + + foreach (var row in RealizedProjectedRows(tableView)) + { + var item = row.DataContext as Person; + Verify.IsNotNull(item, "Every realized row must carry a data item."); + + var cells = GetCellsHost(row).Children.ToList(); + Verify.AreEqual(GetRowCellColumns(row).Count, cells.Count, "Every column must have a cell."); + Verify.AreEqual(item.Name, GetCellText((Grid)cells[0]), "A recycled row's first cell must show its current item."); + Verify.AreEqual(item.Role, GetCellText((Grid)cells[1]), "A recycled row's second cell must show its current item."); + + if (!cellsBefore.TryGetValue(row, out var previous)) + { + continue; + } + + recycledCount++; + for (var i = 0; i < cells.Count; i++) + { + Verify.IsTrue(ReferenceEquals(previous[i], cells[i]), + $"Recycling '{item.Name}' into a row with no cell peers must reuse cell {i}, not regenerate it."); + Verify.IsNotNull(contentBefore[row][i], $"Precondition: cell {i} had generated content before recycling."); + Verify.IsTrue(ReferenceEquals(contentBefore[row][i], GetCellContent((Grid)cells[i])), + $"Recycling '{item.Name}' must keep the generated content of cell {i}."); + } + } + + Log.Comment($"{recycledCount} recycled rows reused their cells."); + Verify.IsGreaterThan(recycledCount, 0, "Precondition: at least one container must have been recycled to a new item."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a recycled row whose cells already have automation peers replaces them, so a held cell provider never silently describes another item.")] + public void VerifyRecycledRowWithCellPeersReplacesItsCells() + { + TableView tableView = null; + List items = null; + var peersBefore = new Dictionary(); + var cellsBefore = new Dictionary(); + + RunOnUIThread.Execute(() => + { + items = MakeManyItems(RecyclingSourceCount); + tableView = CreateScrollableTable(items); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + foreach (var row in RealizedProjectedRows(tableView)) + { + peersBefore[row] = GetCellPeer(row, 0); + cellsBefore[row] = GetRowCell(row, 0); + } + + Verify.IsGreaterThan(peersBefore.Count, 0, "Precondition: rows must be realized before scrolling."); + }); + + ScrollToEnd(tableView); + + RunOnUIThread.Execute(() => + { + var recycledCount = 0; + + foreach (var row in RealizedProjectedRows(tableView)) + { + var item = row.DataContext as Person; + Verify.IsNotNull(item, "Every realized row must carry a data item."); + + var cellPeer = GetCellPeer(row, 0); + var name = cellPeer.GetName(); + Verify.IsTrue(name != null && name.Contains(item.Name), + $"A recycled cell peer must report the current item's value; '{name}' does not mention '{item.Name}'."); + Verify.AreEqual(item.Name, GetCellText(GetRowCell(row, 0)), "A recycled row must show its current item."); + + if (!peersBefore.TryGetValue(row, out var previousPeer)) + { + continue; + } + + recycledCount++; + Verify.IsFalse(ReferenceEquals(previousPeer, cellPeer), + $"A row recycled to '{item.Name}' must not hand out the cell provider of its previous item."); + Verify.IsFalse(ReferenceEquals(cellsBefore[row], GetRowCell(row, 0)), + $"A row recycled to '{item.Name}' while its cells had peers must replace those cells."); + } + + Log.Comment($"{recycledCount} recycled rows replaced their cells."); + Verify.IsGreaterThan(recycledCount, 0, "Precondition: at least one container must have been recycled to a new item."); + }); + } + } + + // Recycled rows must repaint index- and table-dependent chrome for the slot they now occupy. + [TestClass] + public class TableViewRowChromeRecyclingTests : TableViewApiTestBase + { + [TestMethod] + [TestProperty("Description", "Verifies recycled rows carry the banding for their new index and draw grid lines, with and without a row peer.")] + public void VerifyRecycledRowsRepaintBandingAndGridLines() + { + TableView tableView = null; + List items = null; + Brush baseBrush = null; + Brush alternateBrush = null; + + RunOnUIThread.Execute(() => + { + items = MakeManyItems(RecyclingSourceCount); + baseBrush = new SolidColorBrush(Microsoft.UI.Colors.LightGray); + alternateBrush = new SolidColorBrush(Microsoft.UI.Colors.LightBlue); + tableView = CreateScrollableTable(items); + tableView.RowBackground = baseBrush; + tableView.AlternatingRowBackground = alternateBrush; + tableView.GridLinesVisibility = TableViewGridLinesVisibility.All; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + // Every other row gets a peer so both recycle routes (with and without item tracking) are covered. + var rows = RealizedProjectedRows(tableView); + for (var i = 0; i < rows.Count; i += 2) + { + GetRowPeer(rows[i]); + } + + VerifyChrome(tableView, items, baseBrush, alternateBrush, "before scrolling"); + }); + + ScrollBodyTo(tableView, ScrollAxis.Vertical, 37.0 * 7); + RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after a partial scroll")); + + ScrollToEnd(tableView); + RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after scrolling to the end")); + + ScrollToStart(tableView); + RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after scrolling back")); + } + + private static void VerifyChrome( + TableView tableView, + List items, + Brush baseBrush, + Brush alternateBrush, + string context) + { + var rows = RealizedProjectedRows(tableView); + Verify.IsGreaterThan(rows.Count, 0, $"Rows must be realized ({context})."); + + foreach (var row in rows) + { + var item = row.DataContext as Person; + var index = items.IndexOf(item); + Verify.IsGreaterThanOrEqual(index, 0, $"Every realized row must map to a source item ({context})."); + + Verify.AreEqual(item.Name, GetCellText(GetRowCell(row, 0)), $"Row {index} must show its own item ({context})."); + + var expected = (index % 2) == 0 ? baseBrush : alternateBrush; + Verify.AreEqual(expected, row.Background, $"Row {index} must carry the brush for its parity ({context})."); + + // The final row may suppress its bottom line where it meets the table border. + if (index != items.Count - 1) + { + Verify.IsGreaterThan(row.BorderThickness.Bottom, 0.0, $"Row {index} must draw its horizontal grid line ({context})."); + } + + var cell = GetRowCell(row, 0); + Verify.IsGreaterThan(cell.BorderThickness.Right, 0.0, $"Row {index} must draw its vertical grid line ({context})."); + Verify.IsNotNull(cell.BorderBrush, $"Row {index}'s vertical grid line must have a brush ({context})."); + } + } } // Grouping and sorting must not defeat virtualization. diff --git a/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs b/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs index c98627709..18f7e7e1d 100644 --- a/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs @@ -1364,6 +1364,58 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + [TestProperty("Description", "Verifies re-parenting a gripper mid-drag keeps the drag: the stale Unloaded after the new Loaded must not cancel it.")] + public void VerifyReparentingGripperMidDragKeepsDrag() + { + ResizeGripper gripper = null; + StackPanel first = null; + StackPanel second = null; + var completions = new List(); + + RunOnUIThread.Execute(() => + { + gripper = new ResizeGripper { Width = 8, Height = 24 }; + gripper.DragCompleted += (s, e) => completions.Add(e.Canceled); + first = new StackPanel(); + second = new StackPanel(); + first.Children.Add(gripper); + var root = new StackPanel(); + root.Children.Add(first); + root.Children.Add(second); + LoadContent(root); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + gripper.BeginDrag(); + Verify.IsTrue(gripper.IsDragging, "Precondition: the drag starts."); + first.Children.Remove(gripper); + second.Children.Add(gripper); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(gripper.IsLoaded, "Precondition: the gripper is loaded in its new parent."); + Verify.IsTrue(gripper.IsDragging, "A re-parent must not end the drag."); + Verify.AreEqual(0, completions.Count, "A re-parent must not raise DragCompleted."); + + second.Children.Remove(gripper); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(gripper.IsDragging, "A real unload must end the drag."); + Verify.IsTrue(completions.Count == 1 && completions[0], "A real unload cancels the drag once."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies a drag on a gripper that was never loaded completes without touching pointer capture.")] public void VerifyEndDragOnUnloadedGripperRaisesDragCompleted() @@ -2167,7 +2219,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests var cellRight = GetVisualRight(headerCell, wrapper); Log.Comment($"Header cell spans {cellLeft}..{cellRight}."); - var gridLine = headerCell.FindVisualChildByName("TableViewHeaderGridLine"); + var gridLine = headerCell.FindVisualChildByName("PART_GridLine"); Verify.IsNotNull(gridLine, "The header cell must contain its vertical grid line."); Verify.AreEqual(Visibility.Visible, gridLine.Visibility, "Precondition: a non-terminal header grid line must be visible with GridLinesVisibility.All."); diff --git a/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs b/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs index fc174a8ea..7c4c3a80d 100644 --- a/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs @@ -379,6 +379,186 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies SortByColumn refuses a column whose CanSort is false.")] + public void VerifySortByColumnRespectsCanSort() + { + TableView tableView = null; + var returned = true; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + tableView.Columns[0].CanSort = false; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => returned = tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(returned, "SortByColumn must refuse a column with CanSort = false."); + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, + "A column with CanSort = false must not acquire a sort direction."); + + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "a column that opted out of sorting must leave the rows in source order"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies setting CanSort = false on the sorted column clears its sort and raises Sorting/Sorted.")] + public void VerifyCanSortFalseClearsActiveSort() + { + TableView tableView = null; + var sortingArgs = new List(); + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), + "Baseline: the column must sort."); + tableView.Sorting += (sender, args) => sortingArgs.Add(args); + tableView.Sorted += (sender, args) => sortedArgs.Add(args); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), + "baseline: the rows must be sorted"); + tableView.Columns[0].CanSort = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + Verify.AreEqual(SortDirection.None, column.SortDirection, + "Opting a sorted column out of sorting must clear its sort direction."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "clearing the opted-out column's sort must restore source order"); + + var indicator = FindSortIndicator(tableView, 0); + Verify.IsTrue(indicator == null || indicator.Direction == SortIndicatorDirection.None, + "The header arrow must be cleared."); + + Verify.AreEqual(1, sortingArgs.Count, "The clear must raise Sorting once."); + Verify.AreEqual(column, sortingArgs[0].Column, "Sorting must name the opted-out column."); + Verify.AreEqual(SortDirection.None, sortingArgs[0].Direction, "Sorting must request None."); + Verify.AreEqual(1, sortedArgs.Count, "The clear must raise Sorted once."); + Verify.AreEqual(column, sortedArgs[0].Column, "Sorted must name the opted-out column."); + Verify.AreEqual(SortDirection.None, sortedArgs[0].Direction, "Sorted must report None."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a CanSort = false clear still removes the installed sort when the Sorting handler leaves the column without a sort path.")] + public void VerifyCanSortFalseClearRemovesSortWhenHandlerDropsSortPath() + { + TableView tableView = null; + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), + "Baseline: the column must sort."); + tableView.Sorting += (sender, args) => + { + if (args.Direction == SortDirection.None && args.Column is TableViewTextColumn column) + { + column.Binding = null; + } + }; + tableView.Sorted += (sender, args) => sortedArgs.Add(args); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), + "baseline: the rows must be sorted"); + tableView.Columns[0].CanSort = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out column's sort must be cleared."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "the clear must remove the sort the column installed"); + Verify.AreEqual(1, sortedArgs.Count, "The clear must raise Sorted once."); + Verify.AreEqual(SortDirection.None, sortedArgs[0].Direction, "Sorted must report None."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a canceled CanSort = false clear keeps the sort, and SortByColumn(column, None) can still clear it.")] + public void VerifyCanceledCanSortClearCanBeClearedBySortByColumnNone() + { + TableView tableView = null; + var cancel = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + tableView.Sorting += (sender, args) => args.Cancel = cancel; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + Verify.IsTrue(tableView.SortByColumn(column, SortDirection.Ascending), "Baseline: the column must sort."); + + cancel = true; + column.CanSort = false; + cancel = false; + Verify.AreEqual(SortDirection.Ascending, column.SortDirection, + "A Sorting handler that cancels the opt-out clear keeps the sort."); + + Verify.IsFalse(tableView.SortByColumn(column, SortDirection.Descending), + "CanSort = false must still refuse a new direction."); + Verify.IsTrue(tableView.SortByColumn(column, SortDirection.None), + "SortByColumn(column, None) must clear a sort the opted-out column still holds."); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The sort must be cleared."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "clearing the sort must restore source order"); + }); + } + [TestMethod] [TestProperty("Description", "Verifies ClearSort resets every column and restores source order.")] public void VerifyClearSortClearsEveryColumnAndRestoresSourceOrder() @@ -1139,6 +1319,356 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies clearing a column lit by an app-declared TableViewSource sort removes that sort.")] + [TestProperty("data:Clear", "{SortByColumnNone, HeaderInvoke}")] + public void VerifyClearingColumnLitBySourceSortRemovesSourceSort() + { + var clear = ((string)TestContext.DataRow["Clear"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + tableView.Columns[0].SortCycle = TableViewSortCycle.AscendingDescendingNone; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Descending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection, + "Precondition: the source sort lights the Name column."); + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name), + "precondition: the source sort orders the rows"); + + switch (clear) + { + case "SortByColumnNone": + tableView.SortByColumn(tableView.Columns[0], SortDirection.None); + break; + default: + var provider = new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]) + .GetPattern(PatternInterface.Invoke) as IInvokeProvider; + provider.Invoke(); + break; + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, $"{clear}: the column must be cleared."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + $"{clear}: the clear must remove the app-declared sort"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies moving, replacing or ungating the columns keeps an app-declared TableViewSource sort and re-lights the matching column.")] + [TestProperty("data:Change", "{MoveColumn, ReplaceColumns, CanUserSortColumnsFalse}")] + public void VerifyImplicitClearKeepsSourceSort() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Descending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection, + "Precondition: the source sort lights the Name column."); + tableView.Sorted += (s, e) => sortedArgs.Add(e); + + switch (change) + { + case "MoveColumn": + var column = tableView.Columns[0]; + tableView.Columns.RemoveAt(0); + tableView.Columns.Insert(1, column); + break; + case "ReplaceColumns": + tableView.Columns.Clear(); + tableView.Columns.Add(MakeTextColumn("Name")); + tableView.Columns.Add(MakeTextColumn("Role")); + break; + default: + tableView.CanUserSortColumns = false; + break; + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name), + $"{change}: the app-declared sort must stay on the source"); + var nameColumn = tableView.Columns.First(c => c.Header as string == "Name"); + Verify.AreEqual(SortDirection.Descending, nameColumn.SortDirection, + $"{change}: the Name column must re-light from the source sort"); + Verify.IsFalse(sortedArgs.Any(e => e.Column == null && e.Direction == SortDirection.None), + $"{change}: a clear that left the source sort in place must not report that sorting was cleared"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies turning CanUserSortColumns off right after the app sorts the source removes only the table's axis and reports no clear.")] + public void VerifyUngatingAfterAppSourceSortDoesNotReportClear() + { + TableView tableView = null; + TableViewSource source = null; + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the Name column sorts."); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + tableView.Sorted += (s, e) => sortedArgs.Add(e); + // Same tick: the table's own axis is still installed when the gate closes. + source.Sort("Role", SortDirection.Descending); + tableView.CanUserSortColumns = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(sortedArgs.Any(e => e.Column == null && e.Direction == SortDirection.None), + "Removing the table's axis while the app's sort remains must not report that sorting was cleared."); + var roleColumn = tableView.Columns.First(c => c.Header as string == "Role"); + var nameColumn = tableView.Columns.First(c => c.Header as string == "Name"); + Verify.AreEqual(SortDirection.Descending, roleColumn.SortDirection, "The app's Role sort must light its column."); + Verify.AreEqual(SortDirection.None, nameColumn.SortDirection, "The table's own Name sort must be gone."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies filter changes under an app sort no column describes raise no Sorted.")] + [TestProperty("data:Sort", "{KeySelector, UnmatchedPath}")] + public void VerifyFilterUnderUnmatchedAppSortRaisesNoSorted() + { + var sort = ((string)TestContext.DataRow["Sort"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + int sortedCount = 0; + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + if (sort == "KeySelector") + { + source.Sort(item => ((ShapedPerson)item).Name, SortDirection.Ascending); + } + else + { + source.Sort("DepartmentName", SortDirection.Ascending); + } + }); + + SettleLayout(tableView); + + for (int i = 0; i < 3; i++) + { + RunOnUIThread.Execute(() => + { + if (i == 0) + { + tableView.Sorted += (s, e) => sortedCount++; + } + source.Filter(_ => true); + }); + SettleLayout(tableView); + } + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(0, sortedCount, $"{sort}: a filter change must not report a sort change."); + Verify.IsTrue(tableView.Columns.All(c => c.SortDirection == SortDirection.None), $"{sort}: no column describes the app's sort."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies turning sorting off for the table or the column leaves a column the app's source sort lit untouched and raises no sort events.")] + [TestProperty("data:Change", "{CanUserSortColumnsFalse, CanSortFalse}")] + public void VerifyUngatingKeepsAppLitColumnWithoutEvents() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + int sortingCount = 0; + int sortedCount = 0; + int directionChanges = 0; + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Ascending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Ascending, tableView.Columns[0].SortDirection, "Precondition: the source sort lights the Name column."); + tableView.Sorting += (s, e) => sortingCount++; + tableView.Sorted += (s, e) => sortedCount++; + tableView.Columns[0].RegisterPropertyChangedCallback(TableViewColumn.SortDirectionProperty, (s, dp) => directionChanges++); + if (change == "CanSortFalse") + { + tableView.Columns[0].CanSort = false; + } + else + { + tableView.CanUserSortColumns = false; + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(0, sortingCount, "The table installed no sort, so there is nothing to clear and no Sorting."); + Verify.AreEqual(0, sortedCount, "No Sorted may be raised."); + Verify.AreEqual(0, directionChanges, "The column's SortDirection must not flip."); + Verify.AreEqual(SortDirection.Ascending, tableView.Columns[0].SortDirection, "The column keeps describing the app's sort."); + VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), "the app's sort stays on the source"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a sort opt-out rebuilds the headers even when a Sorted handler throws.")] + [TestProperty("data:OptOut", "{CanSort, CanUserSortColumns}")] + public void VerifySortOptOutRebuildsHeadersWhenSortedHandlerThrows() + { + var optOut = ((string)TestContext.DataRow["OptOut"]).Trim(); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.Sorted += (s, e) => throw new InvalidOperationException("Sorted handler fault"); + try + { + if (optOut == "CanSort") + { + tableView.Columns[0].CanSort = false; + } + else + { + tableView.CanUserSortColumns = false; + } + } + catch (Exception e) + { + Log.Comment($"{optOut}: the handler fault surfaced: {e.Message}"); + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var indicator = FindSortIndicator(tableView, 0); + Verify.IsTrue(indicator == null || indicator.Visibility == Visibility.Collapsed, + $"{optOut}: the header must drop its sort affordance even though a Sorted handler threw."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a clear queued by removing the sorted column leaves the app-declared sort of the next ItemsSource alone.")] + public void VerifyColumnRemovalClearKeepsNewItemsSourceSort() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(TableViewSource.From(MakeShapedPeople())); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[1], SortDirection.Ascending), + "Precondition: the Role column sorts.")); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + tableView.Columns.RemoveAt(1); + var next = TableViewSource.From(MakeShapedPeople()); + next.Sort("Name", SortDirection.Descending); + tableView.ItemsSource = next; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name), + "the new ItemsSource must keep the sort the app declared on it"); + Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection, + "The Name column must reflect the new source's sort."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies invoking a column header toggles the sort and updates the sort indicator.")] public void VerifyHeaderInvokeTogglesSortAndUpdatesIndicator() diff --git a/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs b/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs index ff1e47976..3bd5f8fb0 100644 --- a/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs @@ -153,6 +153,136 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } }); } + + [TestMethod] + [TestProperty("Description", "Verifies clearing a column on an unloaded table leaves the sort of the shared TableViewSource its new owner shows.")] + public void VerifyUnloadedTableClearKeepsSharedSourceSort() + { + TableView first = null; + TableView second = null; + TableViewSource shared = null; + StackPanel host = null; + + RunOnUIThread.Execute(() => + { + shared = TableViewSource.From(MakeShapedPeople()); + first = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host = new StackPanel(); + host.Children.Add(first); + LoadContent(host); + }); + + SettleLayout(first); + RunOnUIThread.Execute(() => shared.Sort("Name", SortDirection.Descending)); + SettleLayout(first); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "Precondition: the source sort lights the first table."); + host.Children.Remove(first); + second = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host.Children.Add(second); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "precondition: the new owner shows the source sort"); + first.SortByColumn(first.Columns[0], SortDirection.None); + Verify.AreEqual(SortDirection.None, first.Columns[0].SortDirection, "The unloaded table clears its own column."); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "a table that released the shared source must not clear the sort its new owner shows"); + Verify.AreEqual(SortDirection.Descending, second.Columns[0].SortDirection, "The new owner keeps its indicator."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a clear made before the table first loads still holds after it loads.")] + public void VerifyClearBeforeLoadedStaysCleared() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(TableViewSource.From(MakeShapedPeople()), headers: new[] { "Name", "Role" }, width: 500, height: 300); + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the unloaded table sorts."); + Verify.IsTrue(tableView.ClearSort(), "Precondition: the unloaded table clears its sort."); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The cleared column must stay cleared once the table loads."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), "the rows must stay in source order"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies sorting an unloaded table leaves the shared TableViewSource its new owner shows unchanged.")] + public void VerifyUnloadedTableSortKeepsSharedSourceOrder() + { + TableView first = null; + TableView second = null; + TableViewSource shared = null; + StackPanel host = null; + int sortingCount = 0; + int sortedCount = 0; + + RunOnUIThread.Execute(() => + { + shared = TableViewSource.From(MakeShapedPeople()); + first = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host = new StackPanel(); + host.Children.Add(first); + LoadContent(host); + }); + + SettleLayout(first); + RunOnUIThread.Execute(() => shared.Sort("Name", SortDirection.Descending)); + SettleLayout(first); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "Precondition: the source sort lights the first table."); + host.Children.Remove(first); + second = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host.Children.Add(second); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "precondition: the new owner shows the source sort"); + first.Sorting += (s, e) => sortingCount++; + first.Sorted += (s, e) => sortedCount++; + Verify.IsFalse(first.SortByColumn(first.Columns[0], SortDirection.Ascending), + "A sort the shared source cannot take must report failure."); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "a table that released the shared source must not re-sort the rows its new owner shows"); + Verify.AreEqual(SortDirection.Descending, second.Columns[0].SortDirection, "The new owner keeps its indicator."); + Verify.AreEqual(0, sortingCount, "A refused sort must not raise Sorting."); + Verify.AreEqual(0, sortedCount, "A refused sort must not raise Sorted."); + Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "A refused sort leaves the column state unchanged."); + }); + } } [TestClass] diff --git a/controls/dev/TableView/APITests/TableView_Theming_APITests.cs b/controls/dev/TableView/APITests/TableView_Theming_APITests.cs index 796960c17..ec183277f 100644 --- a/controls/dev/TableView/APITests/TableView_Theming_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Theming_APITests.cs @@ -91,7 +91,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests // Hardcoded rather than looked up: TableViewGroupHeaderPadding is declared at the root // of TableView.xaml, the default-style page, which reaches a control through // DefaultStyleResourceUri and is never merged into Application.Current.Resources. - Verify.AreEqual(new Thickness(8, 6, 16, 6), groupHeader.Padding, + Verify.AreEqual(new Thickness(8, 8, 12, 8), groupHeader.Padding, "TableViewGroupHeader's default style must take Padding from TableViewGroupHeaderPadding."); }); } @@ -280,7 +280,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests // Values mirror Common_themeresources_any.xaml. [TestMethod] - public void VerifyGridLineBrushesMatchFluentStrokeTokens() + public void VerifyGridLineAndOuterBorderBrushesMatchFluentStrokeTokens() { RunOnUIThread.Execute(() => { @@ -288,9 +288,11 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests var dark = RequireBrush(resources, "Default", "TabularSurfaceGridLineBrush"); var light = RequireBrush(resources, "Light", "TabularSurfaceGridLineBrush"); + var darkBorder = RequireBrush(resources, "Default", "TabularSurfaceOuterBorderBrush"); + var lightBorder = RequireBrush(resources, "Light", "TabularSurfaceOuterBorderBrush"); var darkHeader = RequireBrush(resources, "Default", "TabularSurfaceHeaderSeparatorBrush"); var lightHeader = RequireBrush(resources, "Light", "TabularSurfaceHeaderSeparatorBrush"); - if (dark == null || light == null || darkHeader == null || lightHeader == null) + if (dark == null || light == null || darkBorder == null || lightBorder == null || darkHeader == null || lightHeader == null) { return; } @@ -299,11 +301,57 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests "Dark TabularSurfaceGridLineBrush must be DividerStrokeColorDefault (#15FFFFFF)."); Verify.AreEqual(Color.FromArgb(0x0F, 0x00, 0x00, 0x00), light.Color, "Light TabularSurfaceGridLineBrush must be DividerStrokeColorDefault (#0F000000)."); + Verify.AreEqual(Color.FromArgb(0x19, 0x00, 0x00, 0x00), darkBorder.Color, + "Dark TabularSurfaceOuterBorderBrush must be CardStrokeColorDefault (#19000000)."); + Verify.AreEqual(Color.FromArgb(0x0F, 0x00, 0x00, 0x00), lightBorder.Color, + "Light TabularSurfaceOuterBorderBrush must be CardStrokeColorDefault (#0F000000)."); Verify.AreEqual(dark.Color, darkHeader.Color, "Dark header lines must match the body grid lines (DividerStrokeColorDefault)."); Verify.AreEqual(light.Color, lightHeader.Color, "Light header lines must match the body grid lines (DividerStrokeColorDefault)."); }); } + [TestMethod] + public void VerifyOuterBorderAndHeaderRuleUseTheirOwnBrushes() + { + var border = Color.FromArgb(0xFF, 0x20, 0x40, 0xC0); + var gridLine = Color.FromArgb(0xFF, 0xC0, 0x40, 0x20); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceOuterBorderBrush", new SolidColorBrush(border)); + SetApplicationResource("TabularSurfaceHeaderSeparatorBrush", new SolidColorBrush(gridLine)); + + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => + { + Verify.AreEqual(border, (tableView.BorderBrush as SolidColorBrush)?.Color ?? default, + "The outer border must come from TabularSurfaceOuterBorderBrush."); + + var headerRow = tableView.FindVisualChildByName("PART_HeaderRow") as Border; + Verify.IsNotNull(headerRow, "The template must contain PART_HeaderRow."); + Verify.AreEqual(gridLine, (headerRow?.BorderBrush as SolidColorBrush)?.Color ?? default, + "The header bottom rule must come from TabularSurfaceHeaderSeparatorBrush."); + }); + } + finally + { + RunOnUIThread.Execute(() => + { + ClearApplicationResource("TabularSurfaceOuterBorderBrush"); + ClearApplicationResource("TabularSurfaceHeaderSeparatorBrush"); + }); + } + } + // TableView-dev-spec.md:227-231 makes overriding a TabularSurface* key THE supported way to // restyle the table, in place of a monolithic style. If the control caches or hardcodes the // brush instead of resolving it, that story is fiction. @@ -361,6 +409,128 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + public void VerifyHeaderGridLinesShowWhateverTheBodyGridLines() + { + var separatorColor = Color.FromArgb(0xFF, 0x12, 0x9E, 0x5A); + var cases = new[] + { + TableViewGridLinesVisibility.None, + TableViewGridLinesVisibility.Horizontal, + TableViewGridLinesVisibility.Vertical, + TableViewGridLinesVisibility.All, + }; + + TableView tableView = null; + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceHeaderSeparatorBrush", new SolidColorBrush(separatorColor)); + + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + try + { + foreach (var body in cases) + { + RunOnUIThread.Execute(() => + { + tableView.GridLinesVisibility = body; + Content.UpdateLayout(); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var context = $"body {body}"; + bool bodyVertical = body == TableViewGridLinesVisibility.All || body == TableViewGridLinesVisibility.Vertical; + bool bodyHorizontal = body == TableViewGridLinesVisibility.All || body == TableViewGridLinesVisibility.Horizontal; + + var separator = GetHeaderCell(tableView, 0).FindVisualChildByName("PART_GridLine") as Border; + if (separator == null) + { + Verify.Fail("Every header cell must contain its separator."); + return; + } + + Verify.AreEqual(Visibility.Visible, separator.Visibility, + $"The non-terminal header separator is always shown ({context})."); + Verify.AreEqual(VerticalAlignment.Stretch, separator.VerticalAlignment, "Header separators are full height."); + Verify.AreEqual(separatorColor, (separator.Background as SolidColorBrush)?.Color ?? default, + "Header separators paint TabularSurfaceHeaderSeparatorBrush."); + + var headerRow = tableView.FindVisualChildByName("PART_HeaderRow") as Border; + Verify.IsNotNull(headerRow, "The template must contain PART_HeaderRow."); + if (headerRow != null) + { + Verify.IsTrue(headerRow.BorderThickness.Bottom > 0.0, $"The header bottom rule is always shown ({context})."); + Verify.AreEqual(separatorColor, (headerRow.BorderBrush as SolidColorBrush)?.Color ?? default, + "The header bottom rule paints TabularSurfaceHeaderSeparatorBrush."); + } + + var row = FindVisualChildrenByType(tableView).FirstOrDefault(); + if (row == null) + { + Verify.Fail("The TableView should realize at least one row."); + return; + } + + Verify.AreEqual(bodyHorizontal, row.BorderThickness.Bottom > 0.0, + $"Row dividers must follow GridLinesVisibility ({context})."); + Verify.AreEqual(bodyVertical, GetRowCell(row, 0).BorderThickness.Right > 0.0, + $"Body column separators must follow GridLinesVisibility ({context})."); + }); + } + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceHeaderSeparatorBrush")); + } + } + + [TestMethod] + public void VerifyTransparentHeaderSeparatorBrushHidesHeaderLines() + { + var transparent = Color.FromArgb(0x00, 0xFF, 0xFF, 0xFF); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceHeaderSeparatorBrush", new SolidColorBrush(transparent)); + + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => + { + var separator = GetHeaderCell(tableView, 0).FindVisualChildByName("PART_GridLine") as Border; + Verify.IsNotNull(separator, "Every header cell must contain its separator."); + Verify.AreEqual(transparent, (separator?.Background as SolidColorBrush)?.Color ?? default, + "Overriding TabularSurfaceHeaderSeparatorBrush with Transparent must hide the header separators."); + + var headerRow = tableView.FindVisualChildByName("PART_HeaderRow") as Border; + Verify.IsNotNull(headerRow, "The template must contain PART_HeaderRow."); + Verify.AreEqual(transparent, (headerRow?.BorderBrush as SolidColorBrush)?.Color ?? default, + "Overriding TabularSurfaceHeaderSeparatorBrush with Transparent must hide the header bottom rule."); + }); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceHeaderSeparatorBrush")); + } + } + [TestMethod] public void VerifyGridLineBrushAppOverrideWinsInsideElementTheme() { @@ -1072,6 +1242,294 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + public void VerifyHighContrastHoveredAndPressedHeadersSuppressBackplate() + { + foreach (var state in new[] { "HighContrastPointerOver", "HighContrastPressed" }) + { + TableView tableView = null; + TableViewColumnHeader header = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + header = RequireColumnHeader(tableView, 0); + Verify.IsTrue(VisualStateManager.GoToState(header, state, false), $"The header template must define the {state} state."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var presenter = header?.FindVisualChildByName("PART_ContentPresenter") as UIElement; + if (presenter == null) + { + Verify.Fail("The header template must contain PART_ContentPresenter."); + return; + } + + Verify.AreEqual(ElementHighContrastAdjustment.None, presenter.HighContrastAdjustment, + $"The {state} state must turn off the High Contrast text backplate on the header content."); + }); + } + } + + [TestMethod] + public void VerifyDefaultGroupHeaderCountInheritsForegroundThatFollowsStates() + { + TableView tableView = null; + TableViewGroupHeader groupHeader = null; + TextBlock count = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceGroupHeaderCountOpacity", 0.5); + + tableView = CreateGroupedThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + Color PresenterColor() => + ((groupHeader.FindVisualChildByName("PART_ContentPresenter") as ContentPresenter)?.Foreground as SolidColorBrush)?.Color ?? default; + Color CountColor() => (count.Foreground as SolidColorBrush)?.Color ?? default; + + try + { + RunOnUIThread.Execute(() => + { + groupHeader = FindVisualChildrenByType(tableView).FirstOrDefault(); + count = groupHeader?.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock; + if (count == null) + { + Verify.Fail("The default group header content must contain the item-count TextBlock."); + return; + } + + Verify.AreEqual(DependencyProperty.UnsetValue, count.ReadLocalValue(TextBlock.ForegroundProperty), + "The count must inherit Foreground rather than hold its own brush."); + Verify.AreEqual(0.5, count.Opacity, "The count must be dimmed with TabularSurfaceGroupHeaderCountOpacity."); + Verify.AreEqual(PresenterColor(), CountColor(), "At rest the count must use the header's Foreground."); + + var contentPresenter = groupHeader.FindVisualChildByName("PART_ContentPresenter") as ContentPresenter; + var key = contentPresenter == null ? null : + FindVisualChildrenByType(contentPresenter).FirstOrDefault(t => t != count); + Verify.IsNotNull(key, "The default group header content must contain the key TextBlock."); + Verify.AreEqual(DependencyProperty.UnsetValue, key?.ReadLocalValue(TextBlock.FontWeightProperty), + "Key text weight must come from a style, not a local value."); + Verify.AreEqual((ushort)600, key?.FontWeight.Weight ?? (ushort)0, + "Key text must be SemiBold (BodyStrongTextBlockStyle equivalent)."); + Verify.AreEqual(TextWrapping.NoWrap, key?.TextWrapping ?? TextWrapping.Wrap, "Key text stays on one line."); + + Verify.IsTrue(VisualStateManager.GoToState(groupHeader, "PointerOver", false), "PointerOver must exist."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(PresenterColor(), CountColor(), "PointerOver must recolor the count with the key text."); + Verify.IsTrue(VisualStateManager.GoToState(groupHeader, "Disabled", false), "Disabled must exist."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(PresenterColor(), CountColor(), "Disabled must give the count the same disabled brush as the key text."); + Verify.IsTrue(VisualStateManager.GoToState(groupHeader, "Normal", false), "Normal must exist."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual(PresenterColor(), CountColor(), + "Returning to Normal must restore the rest brush.")); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceGroupHeaderCountOpacity")); + } + } + + [TestMethod] + public void VerifyDisabledGroupHeaderCountIsNotDimmedTwice() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceGroupHeaderCountOpacity", 0.5); + + tableView = CreateGroupedThemingTable(); + tableView.IsEnabled = false; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + TableViewGroupHeader header = null; + TextBlock Count() => header?.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock; + + // IsEnabledChanged is raised asynchronously; the header's own handler was attached first. + using (var enabledChanged = new global::System.Threading.AutoResetEvent(false)) + { + try + { + RunOnUIThread.Execute(() => + { + header = FindVisualChildrenByType(tableView).FirstOrDefault(); + var count = Count(); + if (count == null) + { + Verify.Fail("The default group header content must contain the item-count TextBlock."); + return; + } + + Verify.AreEqual(1.0, count.Opacity, "A header loaded disabled must not dim its count on top of the disabled brush."); + header.IsEnabledChanged += (s, e) => + { + try { enabledChanged.Set(); } catch (ObjectDisposedException) { } + }; + tableView.IsEnabled = true; + }); + + Verify.IsTrue(enabledChanged.WaitOne(TimeSpan.FromSeconds(5)), "IsEnabledChanged must be raised."); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(0.5, Count().Opacity, "Enabling must restore TabularSurfaceGroupHeaderCountOpacity."); + tableView.IsEnabled = false; + }); + + Verify.IsTrue(enabledChanged.WaitOne(TimeSpan.FromSeconds(5)), "IsEnabledChanged must be raised."); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual(1.0, Count().Opacity, + "Disabling must not dim the count on top of the disabled brush.")); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceGroupHeaderCountOpacity")); + } + } + } + + [TestMethod] + public void VerifyDisabledGroupHeaderCountFollowsContentTemplateChanges() + { + TableView tableView = null; + DataTemplate customTemplate = null; + const string CustomTemplateXaml = + @" + + + + + "; + + List Counts() => FindVisualChildrenByType(tableView) + .Select(header => header.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock) + .ToList(); + + void VerifyCounts(double expected, string message) + { + var counts = Counts(); + Verify.IsGreaterThan(counts.Count, 0, "Precondition: group headers are realized."); + foreach (var count in counts) + { + if (count == null) + { + Verify.Fail("Every group header must contain the item-count TextBlock."); + return; + } + Verify.AreEqual(expected, count.Opacity, message); + } + } + + try + { + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceGroupHeaderCountOpacity", 0.5); + customTemplate = (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(CustomTemplateXaml); + + tableView = CreateGroupedThemingTable(); + tableView.IsEnabled = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + VerifyCounts(1.0, "Precondition: a disabled default count is not dimmed twice."); + tableView.GroupHeaderTemplate = customTemplate; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + VerifyCounts(0.25, "An app template's same-named TextBlock must keep its own Opacity."); + tableView.GroupHeaderTemplate = null; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => VerifyCounts(1.0, + "A default count realized again while disabled must not be dimmed twice.")); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceGroupHeaderCountOpacity")); + } + } + + [TestMethod] + public void VerifyGroupHeaderCountFollowsAppForegroundStyle() + { + var white = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + tableView = CreateGroupedThemingTable(); + tableView.RequestedTheme = ElementTheme.Light; + var style = new Style(typeof(TableViewGroupHeader)); + style.Setters.Add(new Setter(Control.BackgroundProperty, new SolidColorBrush(Color.FromArgb(0xFF, 0x00, 0x5F, 0xB8)))); + style.Setters.Add(new Setter(Control.ForegroundProperty, new SolidColorBrush(white))); + tableView.Resources[typeof(TableViewGroupHeader)] = style; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var groupHeader = FindVisualChildrenByType(tableView).FirstOrDefault(); + var count = groupHeader?.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock; + if (count == null) + { + Verify.Fail("The default group header content must contain the item-count TextBlock."); + return; + } + + Verify.AreEqual(white, (count.Foreground as SolidColorBrush)?.Color ?? default, + "An app's header Foreground must reach the count so it stays readable on the app's Background."); + }); + } + #endregion #region 13.3 Density @@ -1211,7 +1669,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests IdleSynchronizer.Wait(); VerifyRoundedOnlyWhileKeyboardFocused(tableView, - () => GetHeaderCell(tableView, 0) as Grid, () => GetHeaderCell(tableView, 1), "header cell"); + () => GetHeaderCell(tableView, 0) as TableViewColumnHeader, () => GetHeaderCell(tableView, 1), "header cell"); } [TestMethod] @@ -1234,8 +1692,11 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } // A rounded Grid clips its children and hit-testing, so it may round only while keyboard-focused. + private static CornerRadius RadiusOf(FrameworkElement element) + => element is Control control ? control.CornerRadius : ((Grid)element).CornerRadius; + private static void VerifyRoundedOnlyWhileKeyboardFocused( - TableView tableView, Func getTarget, Func getOther, string what) + TableView tableView, Func getTarget, Func getOther, string what) { var expected = new CornerRadius(0); @@ -1247,10 +1708,10 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.AreNotEqual(new CornerRadius(0), expected, "The row style should round row focus."); var target = getTarget(); - Verify.IsNotNull(target, $"The {what} should be a Grid."); + Verify.IsNotNull(target, $"The {what} should exist."); Verify.IsTrue(target.UseSystemFocusVisuals, $"The {what} should use the system focus visual."); - Verify.AreEqual(new CornerRadius(0), target.CornerRadius, - $"An unfocused {what} must stay square: a rounded Grid clips its children."); + Verify.AreEqual(new CornerRadius(0), RadiusOf(target), + $"An unfocused {what} must stay square: a rounded root clips its children."); target.IsTabStop = true; Verify.IsTrue(target.Focus(FocusState.Keyboard), $"Precondition: the {what} takes keyboard focus."); }); @@ -1263,7 +1724,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsTrue(ReferenceEquals(target, FocusManager.GetFocusedElement(target.XamlRoot)), $"Precondition: keyboard focus stays on the {what}."); Verify.AreEqual(FocusState.Keyboard, target.FocusState, $"Precondition: the {what} is keyboard-focused."); - Verify.AreEqual(expected, target.CornerRadius, + Verify.AreEqual(expected, RadiusOf(target), $"A keyboard-focused {what} must be rounded like the rows' focus rect."); Verify.IsTrue(getOther().Focus(FocusState.Programmatic), "Precondition: focus moves elsewhere."); }); @@ -1273,7 +1734,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests RunOnUIThread.Execute(() => { var target = getTarget(); - Verify.AreEqual(new CornerRadius(0), target.CornerRadius, $"The {what} must drop its radius when it loses focus."); + Verify.AreEqual(new CornerRadius(0), RadiusOf(target), $"The {what} must drop its radius when it loses focus."); target.IsTabStop = true; Verify.IsTrue(target.Focus(FocusState.Pointer), $"Precondition: the {what} takes pointer focus."); }); @@ -1282,7 +1743,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests RunOnUIThread.Execute(() => { - Verify.AreEqual(new CornerRadius(0), getTarget().CornerRadius, + Verify.AreEqual(new CornerRadius(0), RadiusOf(getTarget()), $"Pointer focus shows no focus rect, so the {what} must stay square."); Verify.IsTrue(getOther().Focus(FocusState.Programmatic), "Precondition: focus moves elsewhere."); }); @@ -1308,7 +1769,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Log.Comment($"Programmatic focus on the {what} was coerced to {state}."); Verify.IsTrue(state == FocusState.Keyboard || state == FocusState.Pointer, $"Precondition: programmatic focus on the {what} is coerced to Keyboard or Pointer."); - Verify.AreEqual(state == FocusState.Keyboard ? expected : new CornerRadius(0), target.CornerRadius, + Verify.AreEqual(state == FocusState.Keyboard ? expected : new CornerRadius(0), RadiusOf(target), $"Programmatic focus shows the focus rect only after keyboard input, so the {what} is rounded only then."); }); } @@ -1614,6 +2075,1154 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests #endregion + #region 13.4 Column header style + + private static TableViewColumnHeader RequireColumnHeader(TableView tableView, int index) + { + var header = GetHeaderCell(tableView, index) as TableViewColumnHeader; + Verify.IsNotNull(header, $"Header {index} should be a TableViewColumnHeader."); + return header; + } + + private static string CurrentState(TableViewColumnHeader header, string groupName) + { + var root = VisualTreeHelper.GetChild(header, 0) as FrameworkElement; + Verify.IsNotNull(root, "The header template should have applied."); + var group = VisualStateManager.GetVisualStateGroups(root).FirstOrDefault(g => g.Name == groupName); + Verify.IsNotNull(group, $"The header template should declare {groupName}."); + return group.CurrentState?.Name; + } + + [TestMethod] + [TestProperty("Description", "Verifies TableView.ColumnHeaderStyle reaches every header, TableViewColumn.HeaderStyle wins for its column, and a style Padding/MinHeight beats the density values.")] + public void VerifyColumnHeaderStyleApplies() + { + TableView tableView = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + var stylePadding = new Thickness(20, 1, 20, 1); + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + tableView = CreateThemingTable(); + var tableStyle = new Style(typeof(TableViewColumnHeader)); + tableStyle.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + tableStyle.Setters.Add(new Setter(Control.FontWeightProperty, Microsoft.UI.Text.FontWeights.Bold)); + tableStyle.Setters.Add(new Setter(Control.PaddingProperty, stylePadding)); + tableStyle.Setters.Add(new Setter(FrameworkElement.MinHeightProperty, 60.0)); + tableView.ColumnHeaderStyle = tableStyle; + + var columnStyle = new Style(typeof(TableViewColumnHeader)); + columnStyle.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + tableView.Columns[1].HeaderStyle = columnStyle; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var first = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(tableBrush, first.Background), "ColumnHeaderStyle's Background setter must reach the header."); + Verify.AreEqual((ushort)700, GetHeaderPresenter(tableView, 0).FontWeight.Weight, + "ColumnHeaderStyle's FontWeight setter must reach the header text."); + Verify.AreEqual(stylePadding, first.Padding, "A style Padding must win over the density padding."); + Verify.AreEqual(60.0, first.MinHeight, "A style MinHeight must win over the density row height."); + + var second = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(tableView.Columns[1], second.Column), "A header's Column must be the column it represents."); + Verify.IsTrue(ReferenceEquals(columnBrush, second.Background), "TableViewColumn.HeaderStyle must win over ColumnHeaderStyle."); + Verify.AreEqual((ushort)600, GetHeaderPresenter(tableView, 1).FontWeight.Weight, + "A column style without a FontWeight setter keeps the default SemiBold."); + Verify.AreEqual(40.0, second.MinHeight, "Without a style MinHeight the header follows the density row height."); + + tableView.ColumnHeaderStyle = null; + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(ReferenceEquals(tableBrush, RequireColumnHeader(tableView, 0).Background), + "Clearing ColumnHeaderStyle must restyle the realized headers."); + Verify.AreEqual((ushort)600, GetHeaderPresenter(tableView, 0).FontWeight.Weight, + "Clearing ColumnHeaderStyle must restore the default header weight."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies header Styles that only inherit setters through BasedOn a ContentControl or Control Style apply, and their BasedOn Padding beats the density padding.")] + public void VerifyBasedOnOnlyColumnHeaderStyleApplies() + { + TableView tableView = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + var stylePadding = new Thickness(20, 1, 20, 1); + bool loaded = false; + + // A checked build can stop on a framework assert while loading; fail instead of stalling. + using var watchdog = new global::System.Threading.Timer(_ => + { + if (!global::System.Threading.Volatile.Read(ref loaded)) + { + Environment.FailFast("VerifyBasedOnOnlyColumnHeaderStyleApplies: the TableView did not load within 60 seconds."); + } + }, null, TimeSpan.FromSeconds(60), global::System.Threading.Timeout.InfiniteTimeSpan); + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + var tableBase = new Style(typeof(ContentControl)); + tableBase.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + tableBase.Setters.Add(new Setter(Control.PaddingProperty, stylePadding)); + var columnBase = new Style(typeof(Control)); + columnBase.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = tableBase }; + tableView.Columns[1].HeaderStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = columnBase }; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + global::System.Threading.Volatile.Write(ref loaded, true); + + RunOnUIThread.Execute(() => tableView.Density = TableViewDensity.Compact); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var first = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(tableBrush, first.Background), "A BasedOn Background setter must reach the header."); + Verify.AreEqual(stylePadding, first.Padding, "A BasedOn Padding must win over the density padding."); + + var second = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(columnBrush, second.Background), "TableViewColumn.HeaderStyle's BasedOn Background must win over ColumnHeaderStyle."); + Verify.AreEqual(30.0, second.MinHeight, "Without a style MinHeight the header follows the Compact row height."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a UIElement column Header moves to the new header when the header band is rebuilt.")] + public void VerifyUIElementHeaderSurvivesHeaderRebuild() + { + TableView tableView = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => tableView.ColumnHeaderStyle = new Style(typeof(TableViewColumnHeader))); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => tableView.Density = TableViewDensity.Compact); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), "The rebuilt header must show the column's UIElement Header."); + + DependencyObject current = headerElement; + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, "The UIElement Header must be parented under the rebuilt header."); + Verify.AreEqual(20.0, headerElement.ActualWidth, "The UIElement Header must be laid out in the rebuilt header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a UIElement column Header moves to the new header band when the TableView is re-templated.")] + public void VerifyUIElementHeaderSurvivesRetemplate() + { + TableView tableView = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + tableView.Template = (ControlTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load( + @" + + + + + + + + + + + + + + + "); + tableView.UpdateLayout(); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => VerifyHostedUnderHeader(tableView, 0, headerElement, "after re-templating")); + } + + [TestMethod] + [TestProperty("Description", "Verifies a column with a UIElement Header can move from one TableView to another.")] + public void VerifyUIElementHeaderSurvivesMoveToAnotherTableView() + { + TableView source = null; + TableView target = null; + StackPanel host = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + source = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + source.Columns[0].Header = headerElement; + host = new StackPanel(); + host.Children.Add(source); + LoadContent(host); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = source.Columns[0]; + source.Columns.RemoveAt(0); + target = new TableView(); + target.Columns.Add(column); + // Templating the target rebuilds its headers before the source's queued rebuild runs. + host.Children.Add(target); + host.UpdateLayout(); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => VerifyHostedUnderHeader(target, 0, headerElement, "after the move")); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus inside a UIElement column Header returns to that header after the header band is rebuilt.")] + public void VerifyFocusInUIElementHeaderIsRestoredAfterRebuild() + { + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => tableView.Density = TableViewDensity.Compact); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + DependencyObject current = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after rebuild: {current}"); + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, "Focus must return to the rebuilt header of the column that held it."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a replaced UIElement column Header can be re-parented right away, before the header band rebuilds.")] + public void VerifyReplacedUIElementHeaderCanBeReparentedImmediately() + { + TableView tableView = null; + StackPanel root = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + tableView.Columns[0].Header = headerElement; + root = new StackPanel(); + root.Children.Add(tableView); + LoadContent(root); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + tableView.Columns[0].Header = "Name"; + var other = new StackPanel(); + root.Children.Add(other); + other.Children.Add(headerElement); + Verify.IsTrue(ReferenceEquals(other, VisualTreeHelper.GetParent(headerElement)), "The old Header must move to the new panel."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual("Name", (RequireColumnHeader(tableView, 0).Content as TextBlock)?.Text, + "The header band must show the new Header.")); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus inside a UIElement column Header follows the column when it moves within the same TableView.")] + public void VerifyFocusInUIElementHeaderFollowsColumnMove() + { + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + tableView.Columns.RemoveAt(0); + tableView.Columns.Insert(1, column); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), "Precondition: the moved column's header hosts its Header."); + DependencyObject current = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the move: {current}"); + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, "Focus must follow the moved column to its rebuilt header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies removing a column whose UIElement Header holds focus moves focus to the nearest remaining header, never onto the removed one.")] + public void VerifyFocusInRemovedColumnHeaderMovesToNearestHeader() + { + TableView tableView = null; + CheckBox headerElement = null; + var removedHeaderFocused = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var removedHeader = RequireColumnHeader(tableView, 0); + EventHandler handler = (s, args) => + { + if (ReferenceEquals(args.NewFocusedElement, removedHeader)) + { + removedHeaderFocused = true; + } + }; + FocusManager.GettingFocus += handler; + try + { + tableView.Columns.RemoveAt(0); + } + finally + { + FocusManager.GettingFocus -= handler; + } + Verify.IsFalse(removedHeaderFocused, "Focus must not move onto the removed column's header."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + DependencyObject focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the removal: {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, header), "Focus must move to the nearest remaining header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies replacing the Columns vector while a header holds keyboard focus keeps focus on a header of the table.")] + public void VerifyHeaderFocusStaysInHeaderBandWhenColumnsVectorReplaced() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(RequireColumnHeader(tableView, 1).Focus(FocusState.Keyboard), "Precondition: the second header takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + // A vector no table is subscribed to, holding columns no table owns. + var donor = new TableView(); + var replacement = donor.GetValue(TableView.ColumnsProperty); + donor.SetValue(TableView.ColumnsProperty, new TableView().GetValue(TableView.ColumnsProperty)); + var columns = (IList)replacement; + columns.Add(MakeTextColumn("Name")); + columns.Add(MakeTextColumn("Role")); + + tableView.SetValue(TableView.ColumnsProperty, replacement); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the replacement: {focused}"); + var header = FindVisualChildrenByType(tableView).FirstOrDefault(h => focused != null && IsUnder(focused, h)); + Verify.IsNotNull(header, "Focus must stay on a header of the table."); + Verify.AreEqual(FocusState.Keyboard, (focused as UIElement)?.FocusState ?? FocusState.Unfocused, "Focus keeps its keyboard state."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus in the header of a column that is moved or re-added with no other header to hold focus returns to that column's header.")] + [TestProperty("data:Change", "{MoveOnlyColumn, ClearAndReAddLast}")] + public void VerifyHeaderFocusReturnsWhenNoOtherHeaderCanHoldIt() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + var focusedIndex = change == "ClearAndReAddLast" ? 1 : 0; + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = change == "MoveOnlyColumn" ? CreateTableView(headers: new[] { "Name" }) : CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[focusedIndex].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var columns = tableView.Columns.ToList(); + if (change == "MoveOnlyColumn") + { + tableView.Columns.RemoveAt(0); + tableView.Columns.Insert(0, columns[0]); + } + else + { + tableView.Columns.Clear(); + foreach (var column in columns) + { + tableView.Columns.Add(column); + } + } + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, focusedIndex); + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"{change}: focused {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, header), $"{change}: focus must return to the column's header."); + Verify.AreEqual(FocusState.Keyboard, ((UIElement)focused).FocusState, $"{change}: keyboard focus must stay keyboard focus."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus in the header of the only column moves into the body when that column is removed.")] + public void VerifyFocusInOnlyRemovedColumnHeaderMovesToBody() + { + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(headers: new[] { "Name" }); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => tableView.Columns.RemoveAt(0)); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the removal: {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, GetRowsRepeater(tableView)), "Focus must move into the table body."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus in a header stays in the header band when the columns are replaced by new instances.")] + [TestProperty("data:Change", "{ClearAndAddNew, ReplaceOnlyColumnWithoutRows}")] + public void VerifyHeaderFocusStaysInHeaderBandWhenColumnsReplaced() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = change == "ReplaceOnlyColumnWithoutRows" + ? CreateTableView(new List(), headers: new[] { "Name" }) + : CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + if (change == "ClearAndAddNew") + { + tableView.Columns.Clear(); + tableView.Columns.Add(MakeTextColumn("Name")); + tableView.Columns.Add(MakeTextColumn("Role")); + } + else + { + tableView.Columns[0] = MakeTextColumn("Name"); + } + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"{change}: focused {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, RequireColumnHeader(tableView, 0)), $"{change}: focus must move to the first header of the new columns."); + Verify.AreEqual(FocusState.Keyboard, ((UIElement)focused).FocusState, $"{change}: keyboard focus must stay keyboard focus."); + }); + } + + + [TestMethod] + [TestProperty("Description", "Verifies changing a string Header keeps the header's content and tooltip until the header band rebuilds.")] + public void VerifyStringHeaderChangeKeepsHeaderContentUntilRebuild() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + tableView.Columns[0].HeaderToolTip = "Tip"; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.IsNotNull(ToolTipService.GetToolTip(header), "Precondition: the header has a tooltip."); + tableView.Columns[0].Header = "Renamed"; + Verify.IsNotNull(header.Content, "A string Header change must not blank the header before the rebuild."); + Verify.IsNotNull(ToolTipService.GetToolTip(header), "A string Header change must not drop the tooltip before the rebuild."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual("Renamed", (RequireColumnHeader(tableView, 0).Content as TextBlock)?.Text, + "The header band must show the new Header.")); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header rebuild started from app code while another rebuild releases headers keeps UIElement Headers hosted.")] + public void VerifyReentrantHeaderRebuildKeepsUIElementHeader() + { + TableView tableView = null; + CheckBox headerElement = null; + bool reentered = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + EventHandler handler = (s, args) => + { + if (!reentered && ReferenceEquals(args.OldFocusedElement, headerElement)) + { + reentered = true; + // Rebuilds the header band synchronously, from inside the outer rebuild. + tableView.CanUserResizeColumns = !tableView.CanUserResizeColumns; + } + }; + FocusManager.GettingFocus += handler; + try + { + tableView.Density = TableViewDensity.Compact; + } + finally + { + FocusManager.GettingFocus -= handler; + } + Verify.IsTrue(reentered, "Precondition: releasing the focused Header re-entered the rebuild."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), "The header must show the column's UIElement Header."); + Verify.IsTrue(IsUnder(headerElement, header), "The UIElement Header must be parented under the rebuilt header."); + }); + } + + private static bool IsUnder(DependencyObject element, DependencyObject ancestor) + { + for (var current = element; current != null; current = VisualTreeHelper.GetParent(current)) + { + if (ReferenceEquals(current, ancestor)) + { + return true; + } + } + return false; + } + + private static void VerifyHostedUnderHeader(TableView tableView, int index, FrameworkElement headerElement, string when) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), $"The header must show the column's UIElement Header {when}."); + + DependencyObject current = headerElement; + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, $"The UIElement Header must be parented under the header {when}."); + Verify.AreEqual(20.0, headerElement.ActualWidth, $"The UIElement Header must be laid out {when}."); + } + + [TestMethod] + [TestProperty("Description", "Verifies a disabled TableView puts its headers in Disabled, dimming the header text and sort glyph.")] + public void VerifyDisabledHeaderDimsText() + { + var disabled = Color.FromArgb(0xFF, 0x20, 0x40, 0x60); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceRowForegroundDisabledBrush", new SolidColorBrush(disabled)); + tableView = CreateThemingTable(); + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.AreEqual("Disabled", CurrentState(header, "CommonStates"), "A disabled table's header must be Disabled."); + Verify.AreEqual(disabled, (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default, + "Disabled must dim the header text."); + var glyph = header.FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "A sorted column must host the sort glyph."); + Verify.AreEqual(disabled, (glyph?.Foreground as SolidColorBrush)?.Color ?? default, "Disabled must dim the sort glyph."); + }); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceRowForegroundDisabledBrush")); + } + } + + [TestMethod] + [TestProperty("Description", "Verifies disabling a header in its High Contrast hover state keeps the disabled text and glyph brush, and re-enabling drops it.")] + public void VerifyDisablingHighContrastHoveredHeaderKeepsDisabledForeground() + { + var disabled = Color.FromArgb(0xFF, 0x20, 0x40, 0x60); + TableView tableView = null; + TableViewColumnHeader header = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceRowForegroundDisabledBrush", new SolidColorBrush(disabled)); + tableView = CreateThemingTable(); + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + header = RequireColumnHeader(tableView, 0); + // The hovered High Contrast states, entered directly: a test cannot switch the system theme. + Verify.IsTrue(VisualStateManager.GoToState(header, "PointerOver", false), "PointerOver must exist."); + Verify.IsTrue(VisualStateManager.GoToState(header, "HighContrastPointerOver", false), "HighContrastPointerOver must exist."); + header.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual("Disabled", CurrentState(header, "CommonStates"), "Precondition: the disabled header is Disabled."); + Verify.AreEqual("HighContrastNormal", CurrentState(header, "HighContrastStates"), "Precondition: the hover highlight is gone."); + Verify.AreEqual(disabled, (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default, + "Leaving the High Contrast hover state must not drop the disabled text brush."); + var glyph = header.FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "A sorted column must host the sort glyph."); + Verify.AreEqual(disabled, (glyph?.Foreground as SolidColorBrush)?.Color ?? default, + "Leaving the High Contrast hover state must not drop the disabled glyph brush."); + header.IsEnabled = true; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual("Normal", CurrentState(header, "CommonStates"), "Precondition: the re-enabled header is Normal."); + Verify.AreNotEqual(disabled, (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default, + "Re-enabling must drop the disabled text brush."); + }); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceRowForegroundDisabledBrush")); + } + } + + [TestMethod] + [TestProperty("Description", "Verifies a disabled header's sort glyph follows the header text when its theme brush re-resolves without a visual state change.")] + public void VerifyDisabledSortGlyphFollowsTextAcrossThemeChange() + { + TableView tableView = null; + TableViewColumnHeader header = null; + Color before = default; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + tableView.RequestedTheme = ElementTheme.Light; + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + header = RequireColumnHeader(tableView, 0); + Verify.AreEqual("Disabled", CurrentState(header, "CommonStates"), "Precondition: a disabled table's header is Disabled."); + before = (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default; + + // Re-resolves the header's ThemeResources without re-running its visual states, as a + // High Contrast toggle does. + header.RequestedTheme = ElementTheme.Dark; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var text = (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default; + Verify.AreNotEqual(before, text, "Precondition: the disabled text brush differs between Light and Dark."); + var glyph = header.FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "A sorted column must host the sort glyph."); + Verify.AreEqual(text, (glyph?.Foreground as SolidColorBrush)?.Color ?? default, + "The disabled sort glyph must keep the header text's brush after it re-resolves."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style with an incompatible TargetType is ignored instead of breaking the header band.")] + public void VerifyIncompatibleColumnHeaderStyleIsIgnored() + { + TableView tableView = null; + SolidColorBrush brush = null; + + RunOnUIThread.Execute(() => + { + brush = new SolidColorBrush(Microsoft.UI.Colors.Red); + var buttonStyle = new Style(typeof(Button)); + buttonStyle.Setters.Add(new Setter(Control.BackgroundProperty, brush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = buttonStyle; + tableView.Columns[1].HeaderStyle = buttonStyle; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style targeting another type."); + Verify.IsFalse(ReferenceEquals(brush, header.Background), $"Header {index} must keep its default look."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style whose BasedOn targets an incompatible type is ignored instead of breaking the header band.")] + public void VerifyColumnHeaderStyleWithIncompatibleBasedOnIsIgnored() + { + TableView tableView = null; + SolidColorBrush brush = null; + + RunOnUIThread.Execute(() => + { + brush = new SolidColorBrush(Microsoft.UI.Colors.Red); + var headerStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = new Style(typeof(Button)) }; + headerStyle.Setters.Add(new Setter(Control.BackgroundProperty, brush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = headerStyle; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style based on another type."); + Verify.IsFalse(ReferenceEquals(brush, header.Background), $"Header {index} must keep its default look."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style whose BasedOn chain loops is ignored instead of breaking the header band.")] + public void VerifyColumnHeaderStyleWithBasedOnCycleIsIgnored() + { + TableView tableView = null; + SolidColorBrush brush = null; + + RunOnUIThread.Execute(() => + { + brush = new SolidColorBrush(Microsoft.UI.Colors.Red); + var first = new Style(typeof(TableViewColumnHeader)); + var second = new Style(typeof(TableViewColumnHeader)) { BasedOn = first }; + first.BasedOn = second; + first.Setters.Add(new Setter(Control.BackgroundProperty, brush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = first; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style whose BasedOn chain loops."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style without a TargetType, alone or as a BasedOn, is ignored instead of breaking the header band.")] + public void VerifyUntypedColumnHeaderStyleIsIgnored() + { + TableView tableView = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + var untyped = new Style(); + untyped.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + var untypedBase = new Style(); + untypedBase.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = untyped; + tableView.Columns[1].HeaderStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = untypedBase }; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style chain without a TargetType."); + Verify.IsFalse(ReferenceEquals(tableBrush, header.Background) || ReferenceEquals(columnBrush, header.Background), + $"Header {index} must keep its default look."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies header Styles targeting TableViewColumnHeader's framework base types are applied.")] + public void VerifyColumnHeaderStyleTargetingBaseTypesApplies() + { + TableView tableView = null; + Style tableStyle = null; + Style columnStyle = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + // Framework types: their TargetType names come back as freshly allocated strings. + tableStyle = new Style(typeof(ContentControl)); + tableStyle.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + columnStyle = new Style(typeof(Control)); + columnStyle.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = tableStyle; + tableView.Columns[1].HeaderStyle = columnStyle; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var first = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(tableStyle, first.Style), "A ContentControl Style must apply to the header."); + Verify.IsTrue(ReferenceEquals(tableBrush, first.Background), "The ContentControl Style's setter must reach the header."); + var second = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(columnStyle, second.Style), "A Control Style must apply to the header."); + Verify.IsTrue(ReferenceEquals(columnBrush, second.Background), "The Control Style's setter must reach the header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a disabled header keeps one glyph Foreground binding across visual state updates instead of rebinding each time.")] + public void VerifyDisabledSortGlyphBindingIsNotRecreated() + { + TableView tableView = null; + Control glyph = null; + BindingExpression binding = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + glyph = RequireColumnHeader(tableView, 0).FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "Precondition: a sorted column hosts the sort glyph."); + binding = glyph.GetBindingExpression(Control.ForegroundProperty); + Verify.IsNotNull(binding, "Precondition: the disabled glyph is bound to the header text."); + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the sort direction changes."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(ReferenceEquals(glyph, RequireColumnHeader(tableView, 0).FindVisualChildByName("TableViewSortIndicator")), + "Precondition: the sort change updates the realized header in place."); + Verify.IsTrue(ReferenceEquals(binding, glyph.GetBindingExpression(Control.ForegroundProperty)), + "A state update must keep the glyph's existing binding."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies the default header template provides its parts and hosts the sort glyph and resize gripper in them.")] + public void VerifyColumnHeaderTemplateParts() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + var presenter = header.FindVisualChildByName("PART_ContentPresenter") as ContentPresenter; + Verify.IsNotNull(presenter, "PART_ContentPresenter should exist."); + Verify.IsTrue(ReferenceEquals(presenter, GetHeaderPresenter(tableView, 0)), "The content presenter should be the header's first ContentPresenter."); + Verify.IsNotNull(header.FindVisualChildByName("PART_GridLine") as Border, "PART_GridLine should exist."); + + var indicatorHost = header.FindVisualChildByName("PART_SortIndicatorHost") as Border; + Verify.IsNotNull(indicatorHost, "PART_SortIndicatorHost should exist."); + Verify.IsTrue(indicatorHost.Child is SortIndicator, "A sortable header should host its sort glyph in PART_SortIndicatorHost."); + + var gripperHost = header.FindVisualChildByName("PART_ResizeGripperHost") as Border; + Verify.IsNotNull(gripperHost, "PART_ResizeGripperHost should exist."); + Verify.IsTrue(gripperHost.Child is ResizeGripper, "A resizable header should host its gripper in PART_ResizeGripperHost."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies SortStates follow the column's sort direction and show the glyph only while sorted.")] + public void VerifyColumnHeaderSortStates() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + void VerifyState(string expected, Visibility hostVisibility, string context) + { + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.AreEqual(expected, CurrentState(header, "SortStates"), $"SortStates ({context})."); + var host = (UIElement)header.FindVisualChildByName("PART_SortIndicatorHost"); + Verify.AreEqual(hostVisibility, host.Visibility, $"Sort glyph visibility ({context})."); + Verify.AreEqual("Unsorted", CurrentState(RequireColumnHeader(tableView, 1), "SortStates"), + $"The other column stays Unsorted ({context})."); + }); + } + + VerifyState("Unsorted", Visibility.Collapsed, "initial"); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending))); + IdleSynchronizer.Wait(); + VerifyState("SortAscending", Visibility.Visible, "ascending"); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending))); + IdleSynchronizer.Wait(); + VerifyState("SortDescending", Visibility.Visible, "descending"); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.ClearSort())); + IdleSynchronizer.Wait(); + VerifyState("Unsorted", Visibility.Collapsed, "cleared"); + } + + [TestMethod] + [TestProperty("Description", "Verifies header typography comes from the header Style, not from local values written by the control.")] + public void VerifyColumnHeaderTypographyHasNoLocalValues() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + var presenter = GetHeaderPresenter(tableView, 0); + var text = presenter.Content as TextBlock; + Verify.IsNotNull(text, "A plain string header should render through a TextBlock."); + + foreach (var (element, fontSize, fontWeight, name) in new (DependencyObject, DependencyProperty, DependencyProperty, string)[] + { + (header, Control.FontSizeProperty, Control.FontWeightProperty, "header"), + (presenter, ContentPresenter.FontSizeProperty, ContentPresenter.FontWeightProperty, "content presenter"), + (text, TextBlock.FontSizeProperty, TextBlock.FontWeightProperty, "header text"), + }) + { + Verify.AreEqual(DependencyProperty.UnsetValue, element.ReadLocalValue(fontSize), $"The {name} must not carry a local FontSize."); + Verify.AreEqual(DependencyProperty.UnsetValue, element.ReadLocalValue(fontWeight), $"The {name} must not carry a local FontWeight."); + } + + Verify.AreEqual((ushort)600, text.FontWeight.Weight, "The default header style keeps SemiBold."); + Verify.AreEqual(14.0, text.FontSize, "The default header style keeps TableViewHeaderFontSize."); + }); + } + + #endregion + #region 13.6 Row banding under an element theme // The subtree runs opposite to the app theme so an Application-theme resolution is detectable. diff --git a/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs b/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs index 0b99a438b..1711a951c 100644 --- a/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs @@ -324,6 +324,45 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies recycled rows keep their tooltip and help text when the bound value does not change between items.")] + public void VerifyRecycledRowKeepsToolTipWhenItemsShareTheValue() + { + TableView tableView = null; + var items = MakeManyItems(200); + + RunOnUIThread.Execute(() => + { + tableView = CreateToolTipTable(items, cellToolTipPath: null); + // An explicit Source keeps the value constant across the DataContext reset, so a + // recycled cell gets no property-changed callback to revive its tooltip. + tableView.Columns[0].CellToolTipBinding = new Binding { Source = "Shared role", Mode = BindingMode.OneWay }; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + ScrollBodyTo(tableView, ScrollAxis.Vertical, 2000.0); + ScrollBodyTo(tableView, ScrollAxis.Vertical, 4000.0); + + RunOnUIThread.Execute(() => + { + var rows = GetRealizedRows(tableView); + Verify.IsGreaterThan(rows.Count, 0, "Rows must be realized after the scroll."); + + foreach (var row in rows) + { + var item = row.DataContext as Person; + var wrapper = GetRowCell(row, 0); + var toolTip = RequireToolTip(wrapper, $"the row for {item?.Name}"); + Verify.AreEqual("Shared role", toolTip.Content as string, "A recycled row must keep its tooltip content."); + Verify.IsTrue(toolTip.IsEnabled, "A recycled row's tooltip must be enabled."); + Verify.AreEqual("Shared role", AutomationProperties.GetHelpText(wrapper), + "A recycled row must re-publish the tooltip as help text."); + } + }); + } + [TestMethod] [TestProperty("Description", "Verifies a source PropertyChanged updates a live cell tooltip in place, on the same ToolTip instance.")] public void VerifyItemPropertyChangeUpdatesCellToolTipInPlace() diff --git a/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs b/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs index 8047c5cbd..3b888a57d 100644 --- a/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs +++ b/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs @@ -721,6 +721,25 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests return value; } + internal static bool WaitForStatusText(string expected, int timeoutMs = 10000) + { + var status = FindElement.ById(StatusText); + string value = null; + DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs); + while (status != null) + { + value = status.DocumentText; + if (value == expected || DateTime.Now >= deadline) + { + break; + } + Wait.ForMilliseconds(100); + } + + Log.Comment("Status = '{0}' (expected '{1}').", value, expected); + return value == expected; + } + internal static string ReadEditReport() => ReadPageReadout(EditColumnReport); internal static string ReadEditorProbe() => ReadPageReadout(EditorProbe); diff --git a/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs b/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs index e31f24c37..bd80f784b 100644 --- a/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs +++ b/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs @@ -163,6 +163,41 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests } } + [TestMethod] + [TestProperty("Description", "Verifies pinning and unpinning a column raises a Transform.CanResize property-changed event on its header, so a UIA client's cached value stays current.")] + public void ColumnPinChangeRaisesCanResizePropertyChanged() + { + using (var setup = new TestSetupHelper(PageName)) + { + UIObject score = GetHeader(BasicTable, "Score"); + var togglePin = FindElement.ById