diff --git a/.github/skills/formatting-log/SKILL.md b/.github/skills/formatting-log/SKILL.md new file mode 100644 index 00000000000..c0204120c4e --- /dev/null +++ b/.github/skills/formatting-log/SKILL.md @@ -0,0 +1,83 @@ +--- +name: formatting-log +description: Import Razor formatting log zips that the user has already downloaded from Azure DevOps feedback tickets or GitHub issues into FormattingLogTest, validate whether the captured problem still reproduces, and if needed drive a minimal repro plus fix workflow. +--- + +# Razor Formatting Log Investigation + +Use this skill when: +- investigating a Razor formatting issue from Visual Studio or VS Code feedback +- given an Azure DevOps work item, Developer Community ticket, or GitHub issue that includes a formatting log zip +- asked to import a `Full_*_razor.zip` or `Range_*_razor.zip` archive into the test suite + +## Goal + +1. Get a local path to the formatting log archive. +2. Import every file from that zip into `src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\TestFiles\FormattingLog\\`. +3. Add a matching `[Fact]` to `FormattingLogTest.cs`. +4. Run the imported formatting log test. +5. If it passes, report that the issue captured in the logs no longer reproduces. +6. If it fails, keep the imported repro, reduce it to a minimal `DocumentFormattingTest`, and then fix the product code. + +## Finding the archive + +### Azure DevOps / VS feedback ticket + +1. Read the work item and comments with the Azure DevOps tools so you know which attachment to ask for. +2. Do **not** try to download Developer Community or Azure DevOps feedback attachments yourself. The available tooling cannot reliably fetch those private files. +3. Ask the user to download the `Full_*_razor.zip` or `Range_*_razor.zip` attachment locally and give you the absolute zip path. +4. Continue only after the user has provided that local path. + +### GitHub issue + +1. Read the issue body and comments. +2. Look for uploaded zip attachments or linked archives with the same naming pattern. +3. If the archive is directly downloadable, download it to a temporary folder. Otherwise ask the user to download it and provide a local path. + +## Import the logs + +Use the helper script in this skill: + +```powershell +.\.github\skills\formatting-log\scripts\Import-FormattingLog.ps1 ` + -ZipPath C:\Users\dawengie\Downloads\Range_Index_razor.zip ` + -WorkItemUrl https://dev.azure.com/devdiv/DevDiv/_workitems/edit/2878103 ` + -TestName RanOutOfOriginalLines +``` + +- `-ZipPath` must be a local file path. For Developer Community and Azure DevOps feedback tickets, ask the user to download the archive first instead of trying to fetch it yourself. +- `-TestName` defaults to a sanitized name derived from the zip filename. Override it when you want a shorter or more descriptive repro name. +- Keep the folder name and the test name identical. +- Use `-Expectation Null` only when the correct outcome is that all edits should be filtered out. Otherwise keep the default `NotNull`. +- The script copies every extracted file into the new test asset folder and appends the matching test method to `FormattingLogTest.cs`. + +## Run the imported test + +```powershell +dotnet test src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\Microsoft.VisualStudio.LanguageServices.Razor.Test.csproj --filter "FullyQualifiedName~FormattingLogTest." +``` + +Do **not** run `dotnet test` at the repo root. + +## Interpreting the result + +- **Test passes**: report that the problem captured in the logs no longer reproduces. +- **Test fails**: continue with a minimal repro and product fix. + +## When the imported log test fails + +1. Commit the imported formatting log assets and `FormattingLogTest` change by themselves. +2. Reduce the failure to a focused repro in `src\Razor\test\Microsoft.CodeAnalysis.Razor.CohostingShared.Test\Formatting\DocumentFormattingTest.cs`. +3. Commit the minimal repro by itself. +4. Fix the product code. +5. Commit the fix by itself. +6. Re-run the imported `FormattingLogTest` plus the focused `DocumentFormattingTest` coverage. + +The formatting-log test, the minimal repro, and the fix should stay as separate commits during the investigation. + +## Notes + +- If the user only gives you a Developer Community or Azure DevOps ticket URL, stop and ask them to download the zip before proceeding. +- `FormattingLogTest` accepts both real range payloads and literal `null` `Range.json` files from full-document logs. +- Imported log zips often contain more files than the test currently consumes. Keep them all in the test asset folder. +- Prefer adding `[WorkItem("...")]` with the originating Azure DevOps or GitHub URL on the imported test. diff --git a/.github/skills/formatting-log/scripts/Import-FormattingLog.ps1 b/.github/skills/formatting-log/scripts/Import-FormattingLog.ps1 new file mode 100644 index 00000000000..d98d439cdb5 --- /dev/null +++ b/.github/skills/formatting-log/scripts/Import-FormattingLog.ps1 @@ -0,0 +1,221 @@ +<# +.SYNOPSIS +Imports a locally downloaded Razor formatting log zip into FormattingLogTest. + +.DESCRIPTION +Developer Community and Azure DevOps feedback attachments must be downloaded by the user first. +Pass the local archive path with -ZipPath. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ZipPath, + + [string]$TestName, + + [string]$WorkItemUrl, + + [ValidateSet('NotNull', 'Null')] + [string]$Expectation = 'NotNull', + + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($ZipPath -match '^[A-Za-z][A-Za-z0-9+\-.]*://') +{ + throw "ZipPath must be a local file path. Download the formatting log archive first, then pass the local zip path." +} + +function Get-RepositoryRoot { + return (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +} + +function ConvertTo-TestName { + param( + [Parameter(Mandatory = $true)] + [string]$ZipBaseName + ) + + $candidate = $ZipBaseName + if ($candidate -match '^(Full|Range)_(?.+)_razor$') + { + $candidate = $Matches['Name'] + } + + $parts = $candidate -split '[^A-Za-z0-9]+' | Where-Object { $_ } + if (-not $parts) + { + throw "Couldn't derive a test name from '$ZipBaseName'. Pass -TestName explicitly." + } + + $normalized = ($parts | ForEach-Object { + if ($_.Length -eq 1) + { + $_.ToUpperInvariant() + } + else + { + $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) + } + }) -join '' + + if ($normalized -match '^[0-9]') + { + $normalized = "Case$normalized" + } + + return $normalized +} + +function Assert-ValidTestName { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') + { + throw "Test name '$Name' is not a valid C# identifier. Pass -TestName with a valid identifier." + } +} + +function Get-ContentRoot { + param( + [Parameter(Mandatory = $true)] + [string]$ExtractPath + ) + + $items = @(Get-ChildItem -LiteralPath $ExtractPath -Force) + if ($items.Count -eq 1 -and $items[0].PSIsContainer) + { + return $items[0].FullName + } + + return $ExtractPath +} + +function Copy-FormattingLogFiles { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + + [Parameter(Mandatory = $true)] + [string]$DestinationPath + ) + + foreach ($item in Get-ChildItem -LiteralPath $SourcePath -Force) + { + Copy-Item -LiteralPath $item.FullName -Destination $DestinationPath -Recurse -Force + } +} + +function Add-FormattingLogTest { + param( + [Parameter(Mandatory = $true)] + [string]$TestFilePath, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [string]$IssueUrl, + + [Parameter(Mandatory = $true)] + [ValidateSet('NotNull', 'Null')] + [string]$AssertionType, + + [switch]$OverwriteExisting + ) + + $contents = [System.IO.File]::ReadAllText($TestFilePath) + $methodPattern = "(?m)^\s*public\s+async\s+Task\s+$([regex]::Escape($Name))\s*\(" + if ([regex]::IsMatch($contents, $methodPattern)) + { + if ($OverwriteExisting) + { + return + } + + throw "FormattingLogTest already contains a test named '$Name'." + } + + $newline = "`r`n" + $workItemAttribute = if ([string]::IsNullOrWhiteSpace($IssueUrl)) + { + '' + } + else + { + " [WorkItem(`"$IssueUrl`")]$newline" + } + + $assertion = if ($AssertionType -eq 'Null') { 'Assert.Null' } else { 'Assert.NotNull' } + $methodBlock = " [Fact]$newline$workItemAttribute public async Task $Name()$newline => $assertion(await GetFormattingEditsAsync());$newline$newline" + + $marker = ' private async Task GetFormattingEditsAsync' + $markerIndex = $contents.IndexOf($marker) + if ($markerIndex -lt 0) + { + throw "Couldn't find the insertion point in '$TestFilePath'." + } + + $updatedContents = $contents.Insert($markerIndex, $methodBlock) + $encoding = [System.Text.UTF8Encoding]::new($true) + [System.IO.File]::WriteAllText($TestFilePath, $updatedContents, $encoding) +} + +$resolvedZipPath = (Resolve-Path -LiteralPath $ZipPath).Path +$repoRoot = Get-RepositoryRoot +$testFilePath = Join-Path $repoRoot 'src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\Cohost\Formatting\FormattingLogTest.cs' +$assetRoot = Join-Path $repoRoot 'src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\TestFiles\FormattingLog' + +if (-not $TestName) +{ + $TestName = ConvertTo-TestName -ZipBaseName ([System.IO.Path]::GetFileNameWithoutExtension($resolvedZipPath)) +} + +Assert-ValidTestName -Name $TestName + +$destinationPath = Join-Path $assetRoot $TestName +if (Test-Path -LiteralPath $destinationPath) +{ + if (-not $Force) + { + throw "Destination folder '$destinationPath' already exists. Pass -Force to overwrite its contents." + } + + Remove-Item -LiteralPath $destinationPath -Recurse -Force +} + +$extractPath = Join-Path ([System.IO.Path]::GetTempPath()) ("formatting-log-" + [Guid]::NewGuid().ToString('N')) + +try +{ + New-Item -ItemType Directory -Path $extractPath | Out-Null + Expand-Archive -LiteralPath $resolvedZipPath -DestinationPath $extractPath -Force + + $contentRoot = Get-ContentRoot -ExtractPath $extractPath + if (-not (Get-ChildItem -LiteralPath $contentRoot -Force)) + { + throw "The archive '$resolvedZipPath' did not contain any files to import." + } + + New-Item -ItemType Directory -Path $destinationPath | Out-Null + Copy-FormattingLogFiles -SourcePath $contentRoot -DestinationPath $destinationPath + + Add-FormattingLogTest -TestFilePath $testFilePath -Name $TestName -IssueUrl $WorkItemUrl -AssertionType $Expectation -OverwriteExisting:$Force +} +finally +{ + if (Test-Path -LiteralPath $extractPath) + { + Remove-Item -LiteralPath $extractPath -Recurse -Force + } +} + +Write-Host "Imported formatting log archive '$resolvedZipPath' as test '$TestName'." +Write-Host "Assets: $destinationPath" +Write-Host "Test file: $testFilePath" diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/Formatting/FormattingLogTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/Formatting/FormattingLogTest.cs index b09e9074393..3de79c313d2 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/Formatting/FormattingLogTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/Formatting/FormattingLogTest.cs @@ -58,6 +58,11 @@ public async Task CSSWrappedToMultipleLines() public async Task MultiLineLambda() => Assert.NotNull(await GetFormattingEditsAsync()); + [Fact] + [WorkItem("https://developercommunity.visualstudio.com/t/Razor-Formatting-Feature---Internal-Erro/11068847")] + public async Task GameTracAdmin() + => Assert.NotNull(await GetFormattingEditsAsync()); + private async Task GetFormattingEditsAsync([CallerMemberName] string? testName = null) { var contents = GetResource(testName.AssumeNotNull(), "InitialDocument.txt").AssumeNotNull(); @@ -81,7 +86,7 @@ public async Task MultiLineLambda() } TextSpan span = default; - if (GetResource(testName, "Range.json") is { } rangeFile) + if (GetResource(testName, "Range.json") is { } rangeFile && rangeFile != "null") { var linePositionSpan = (LinePositionSpan)JsonSerializer.Deserialize(rangeFile, typeof(LinePositionSpan), JsonHelpers.JsonSerializerOptions).AssumeNotNull(); span = sourceText.GetTextSpan(linePositionSpan); @@ -90,7 +95,7 @@ public async Task MultiLineLambda() var formattingService = (RazorFormattingService)OOPExportProvider.GetExportedValue(); formattingService.GetTestAccessor().SetFormattingLoggerFactory(new TestFormattingLoggerFactory(TestOutputHelper)); - return await GetFormattingEditsAsync(document, htmlEdits, span: default, options.CodeBlockBraceOnNextLine, options.AttributeIndentStyle, options.InsertSpaces, options.TabSize, options.CSharpSyntaxFormattingOptions.AssumeNotNull()); + return await GetFormattingEditsAsync(document, htmlEdits, span, options.CodeBlockBraceOnNextLine, options.AttributeIndentStyle, options.InsertSpaces, options.TabSize, options.CSharpSyntaxFormattingOptions.AssumeNotNull()); } private string? GetResource(string testName, string name) @@ -103,6 +108,7 @@ public async Task MultiLineLambda() return null; } - return testFile.ReadAllText(); + // Formatting logs capture absolute spans against the original file contents, so we must not normalize line endings. + return testFile.ReadAllText(normalizeLineEndings: false); } } diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/.gitattributes b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/.gitattributes new file mode 100644 index 00000000000..af71318d1d7 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/.gitattributes @@ -0,0 +1,2 @@ +# Formatting-log text artifacts come directly from customer machines and must be preserved byte-for-byte. +*.txt -text diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSSWrappedToMultipleLines/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSSWrappedToMultipleLines/InitialDocument.txt index f3696115f86..5f62ee30a2b 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSSWrappedToMultipleLines/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSSWrappedToMultipleLines/InitialDocument.txt @@ -1,331 +1,331 @@ -@page "/networking" -@using KalTire.KAOSMgr.Features.Azure.Networking.Models -@using KalTire.KAOSMgr.Features.Azure.Networking.Components -@using Telerik.Blazor -@using Telerik.Blazor.Components -@using Telerik.SvgIcons -@inject Services.IpamDataService IpamService - -@attribute [Authorize(Roles = "KAOS.Admin,KAOS.Azure.Networking")] - -
- - -
-

Azure Networking

- @if (_outerTabId == "ipam" && !_loading && string.IsNullOrEmpty(_error)) - { -
-
- Total IPs - @_stats.TotalIPs - @_stats.PublicIPs pub · @_stats.PrivateIPs priv -
-
- IP Usage - @_stats.AssignedPublicIPs / @_stats.PublicIPs - @_stats.UnassignedPublicIPs unassigned -
-
- Subnets - @_stats.TotalSubnets - @_stats.CriticalSubnets crit · @_stats.WarningSubnets warn -
-
- VNets - @_stats.VNets -
-
- Conflicts - @_stats.Conflicts -
-
- Orphans - @_stats.OrphanResources -
-
- } -
-
- Various networking related tooling. - - - - @* TabPosition="@TabPosition.Left" *@ - - - -
-
Azure IP Address Management
- Manage the Azure IP Address space. - @if (_loading) - { -
-
-

Loading Azure resources...

-
- } - else if (!string.IsNullOrEmpty(_error)) - { -
-

@_error

- Retry -
- } - else - { -
- @* ── Filter Panel ── *@ -
-
- @* Search *@ -
- - -
- - @* IP Type Filter *@ -
- - - - All - - - Public - - - Private - - -
- - @* Allocation Method Filter *@ -
- - - - All - - - Static - - - Dynamic - - -
- - @* Subscriptions Filter *@ -
-
- -
- All - | - None -
-
- - - - - -
- Selected: @(_filter.Subscriptions?.Count ?? 0) / @_subscriptions.Count -
-
-
-
- - @* Refresh Button *@ -
- -
-
-
- - @* ── Main Content ── *@ -
- @* Tab Bar *@ - - - - - - - - - - - - - - - - - - - - -
-
- } -
-
- - - -
-
-
-
- - \ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSharpStringLiteral/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSharpStringLiteral/InitialDocument.txt index d36438fa3bc..0bee7061ac7 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSharpStringLiteral/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/CSharpStringLiteral/InitialDocument.txt @@ -1,3 +1,3 @@ -@{ - var s1 = " test test "; +@{ + var s1 = " test test "; } \ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/HtmlChanges.json b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/HtmlChanges.json new file mode 100644 index 00000000000..9b5f1a9a8a9 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/HtmlChanges.json @@ -0,0 +1 @@ +[{"span":{"start":992,"length":4},"newText":""},{"span":{"start":1041,"length":4},"newText":""},{"span":{"start":1103,"length":4},"newText":""},{"span":{"start":1196,"length":4},"newText":""},{"span":{"start":1248,"length":4},"newText":""},{"span":{"start":1290,"length":4},"newText":""},{"span":{"start":1333,"length":4},"newText":""},{"span":{"start":1375,"length":4},"newText":""},{"span":{"start":1418,"length":4},"newText":""},{"span":{"start":1486,"length":4},"newText":""},{"span":{"start":1568,"length":4},"newText":""},{"span":{"start":1657,"length":4},"newText":""},{"span":{"start":1729,"length":4},"newText":""},{"span":{"start":1760,"length":4},"newText":""},{"span":{"start":1812,"length":4},"newText":""},{"span":{"start":1878,"length":4},"newText":""},{"span":{"start":1908,"length":8},"newText":""},{"span":{"start":1975,"length":8},"newText":""},{"span":{"start":2073,"length":8},"newText":""},{"span":{"start":2112,"length":4},"newText":""},{"span":{"start":2142,"length":4},"newText":""},{"span":{"start":2199,"length":4},"newText":""},{"span":{"start":2229,"length":8},"newText":""},{"span":{"start":2316,"length":8},"newText":""},{"span":{"start":2416,"length":8},"newText":""},{"span":{"start":2481,"length":8},"newText":""},{"span":{"start":2519,"length":12},"newText":""},{"span":{"start":2640,"length":8},"newText":""},{"span":{"start":2678,"length":8},"newText":""},{"span":{"start":2719,"length":8},"newText":""},{"span":{"start":2757,"length":12},"newText":""},{"span":{"start":2872,"length":8},"newText":""},{"span":{"start":2906,"length":8},"newText":""},{"span":{"start":2946,"length":8},"newText":""},{"span":{"start":3012,"length":8},"newText":""},{"span":{"start":3046,"length":12},"newText":""},{"span":{"start":3124,"length":12},"newText":""},{"span":{"start":3309,"length":12},"newText":""},{"span":{"start":3374,"length":12},"newText":""},{"span":{"start":3454,"length":12},"newText":""},{"span":{"start":3561,"length":12},"newText":""},{"span":{"start":3604,"length":12},"newText":""},{"span":{"start":3766,"length":12},"newText":""},{"span":{"start":3936,"length":12},"newText":""},{"span":{"start":4037,"length":12},"newText":""},{"span":{"start":4075,"length":16},"newText":""},{"span":{"start":4144,"length":16},"newText":""},{"span":{"start":4232,"length":16},"newText":""},{"span":{"start":4385,"length":16},"newText":""},{"span":{"start":4432,"length":12},"newText":""},{"span":{"start":4470,"length":12},"newText":""},{"span":{"start":4627,"length":12},"newText":""},{"span":{"start":4710,"length":12},"newText":""},{"span":{"start":4748,"length":16},"newText":""},{"span":{"start":4846,"length":16},"newText":""},{"span":{"start":4952,"length":16},"newText":""},{"span":{"start":4999,"length":12},"newText":""},{"span":{"start":5037,"length":8},"newText":""},{"span":{"start":5071,"length":8},"newText":""},{"span":{"start":5108,"length":8},"newText":""},{"span":{"start":5142,"length":12},"newText":""},{"span":{"start":5241,"length":8},"newText":""},{"span":{"start":5275,"length":4},"newText":""},{"span":{"start":5301,"length":4},"newText":""},{"span":{"start":5328,"length":4},"newText":""},{"span":{"start":5351,"length":4},"newText":""},{"span":{"start":5375,"length":4},"newText":""},{"span":{"start":5417,"length":4},"newText":""},{"span":{"start":5460,"length":4},"newText":""},{"span":{"start":5528,"length":4},"newText":""},{"span":{"start":5610,"length":4},"newText":""},{"span":{"start":5699,"length":4},"newText":""},{"span":{"start":5775,"length":4},"newText":""},{"span":{"start":5806,"length":4},"newText":""},{"span":{"start":5901,"length":4},"newText":""},{"span":{"start":5967,"length":4},"newText":""},{"span":{"start":6054,"length":4},"newText":""},{"span":{"start":6144,"length":4},"newText":""},{"span":{"start":6205,"length":4},"newText":""},{"span":{"start":6238,"length":4},"newText":""},{"span":{"start":6276,"length":12},"newText":""},{"span":{"start":6352,"length":4},"newText":""},{"span":{"start":6474,"length":4},"newText":""},{"span":{"start":6556,"length":4},"newText":""},{"span":{"start":6629,"length":4},"newText":""},{"span":{"start":6671,"length":8},"newText":""},{"span":{"start":6775,"length":4},"newText":""},{"span":{"start":6817,"length":4},"newText":""},{"span":{"start":6862,"length":4},"newText":""},{"span":{"start":6904,"length":8},"newText":""},{"span":{"start":6988,"length":4},"newText":""},{"span":{"start":7030,"length":4},"newText":""},{"span":{"start":7075,"length":4},"newText":""},{"span":{"start":7121,"length":4},"newText":""},{"span":{"start":7242,"length":4},"newText":""},{"span":{"start":7327,"length":4},"newText":""},{"span":{"start":7463,"length":4},"newText":""},{"span":{"start":7535,"length":4},"newText":""},{"span":{"start":7577,"length":8},"newText":""},{"span":{"start":7681,"length":4},"newText":""},{"span":{"start":7723,"length":4},"newText":""},{"span":{"start":7768,"length":4},"newText":""},{"span":{"start":7810,"length":8},"newText":""},{"span":{"start":7900,"length":4},"newText":""},{"span":{"start":7942,"length":4},"newText":""},{"span":{"start":7990,"length":4},"newText":""},{"span":{"start":8032,"length":4},"newText":""},{"span":{"start":8071,"length":4},"newText":""},{"span":{"start":8137,"length":4},"newText":""},{"span":{"start":8171,"length":8},"newText":""},{"span":{"start":8253,"length":8},"newText":""},{"span":{"start":8366,"length":8},"newText":""},{"span":{"start":8409,"length":4},"newText":""},{"span":{"start":8443,"length":4},"newText":""},{"span":{"start":8516,"length":4},"newText":""},{"span":{"start":8550,"length":8},"newText":""},{"span":{"start":8682,"length":8},"newText":""},{"span":{"start":8731,"length":8},"newText":""},{"span":{"start":8774,"length":4},"newText":""},{"span":{"start":8804,"length":4},"newText":""},{"span":{"start":8835,"length":4},"newText":""},{"span":{"start":8862,"length":4},"newText":""},{"span":{"start":8885,"length":4},"newText":""},{"span":{"start":8909,"length":4},"newText":""},{"span":{"start":8952,"length":4},"newText":""},{"span":{"start":8995,"length":4},"newText":""},{"span":{"start":9063,"length":4},"newText":""},{"span":{"start":9145,"length":4},"newText":""},{"span":{"start":9234,"length":4},"newText":""},{"span":{"start":9325,"length":4},"newText":""},{"span":{"start":9356,"length":4},"newText":""},{"span":{"start":9408,"length":4},"newText":""},{"span":{"start":9480,"length":4},"newText":""},{"span":{"start":9648,"length":4},"newText":""},{"span":{"start":9758,"length":4},"newText":""},{"span":{"start":9926,"length":4},"newText":""},{"span":{"start":10078,"length":4},"newText":""},{"span":{"start":10209,"length":4},"newText":""},{"span":{"start":10239,"length":4},"newText":""},{"span":{"start":10266,"length":4},"newText":""},{"span":{"start":10289,"length":4},"newText":""},{"span":{"start":10308,"length":4},"newText":""},{"span":{"start":10328,"length":4},"newText":""},{"span":{"start":10368,"length":4},"newText":""},{"span":{"start":10422,"length":4},"newText":""},{"span":{"start":10523,"length":4},"newText":""},{"span":{"start":10604,"length":4},"newText":""},{"span":{"start":10742,"length":4},"newText":""},{"span":{"start":10856,"length":4},"newText":""},{"span":{"start":10953,"length":4},"newText":""},{"span":{"start":10979,"length":4},"newText":""},{"span":{"start":11002,"length":4},"newText":""},{"span":{"start":11050,"length":4},"newText":""},{"span":{"start":11108,"length":4},"newText":""},{"span":{"start":11130,"length":8},"newText":""},{"span":{"start":11188,"length":8},"newText":""},{"span":{"start":11274,"length":8},"newText":""},{"span":{"start":11305,"length":4},"newText":""},{"span":{"start":11327,"length":4},"newText":""},{"span":{"start":11408,"length":4},"newText":""},{"span":{"start":11430,"length":8},"newText":""},{"span":{"start":11499,"length":8},"newText":""},{"span":{"start":11592,"length":8},"newText":""},{"span":{"start":11698,"length":8},"newText":""},{"span":{"start":11729,"length":4},"newText":""},{"span":{"start":11751,"length":4},"newText":""},{"span":{"start":11776,"length":4},"newText":""},{"span":{"start":11798,"length":8},"newText":""},{"span":{"start":11857,"length":8},"newText":""},{"span":{"start":11964,"length":8},"newText":""},{"span":{"start":12028,"length":8},"newText":""},{"span":{"start":12073,"length":8},"newText":""},{"span":{"start":12128,"length":8},"newText":""},{"span":{"start":12185,"length":8},"newText":""},{"span":{"start":12241,"length":8},"newText":""},{"span":{"start":12312,"length":8},"newText":""},{"span":{"start":12387,"length":8},"newText":""},{"span":{"start":12447,"length":8},"newText":""},{"span":{"start":12509,"length":8},"newText":""},{"span":{"start":12560,"length":8},"newText":""},{"span":{"start":12598,"length":8},"newText":""},{"span":{"start":12639,"length":8},"newText":""},{"span":{"start":12683,"length":8},"newText":""},{"span":{"start":12763,"length":8},"newText":""},{"span":{"start":12801,"length":12},"newText":""},{"span":{"start":12947,"length":12},"newText":""},{"span":{"start":13033,"length":12},"newText":""},{"span":{"start":13143,"length":12},"newText":""},{"span":{"start":13265,"length":12},"newText":""},{"span":{"start":13367,"length":12},"newText":""},{"span":{"start":13473,"length":12},"newText":""},{"span":{"start":13526,"length":12},"newText":""},{"span":{"start":13637,"length":12},"newText":""},{"span":{"start":13687,"length":16},"newText":""},{"span":{"start":13817,"length":12},"newText":""},{"span":{"start":13867,"length":12},"newText":""},{"span":{"start":13920,"length":12},"newText":""},{"span":{"start":13970,"length":16},"newText":""},{"span":{"start":14056,"length":12},"newText":""},{"span":{"start":14102,"length":12},"newText":""},{"span":{"start":14152,"length":12},"newText":""},{"span":{"start":14231,"length":12},"newText":""},{"span":{"start":14284,"length":12},"newText":""},{"span":{"start":14378,"length":12},"newText":""},{"span":{"start":14428,"length":16},"newText":""},{"span":{"start":14537,"length":16},"newText":""},{"span":{"start":14641,"length":16},"newText":""},{"span":{"start":14775,"length":16},"newText":""},{"span":{"start":14835,"length":12},"newText":""},{"span":{"start":14885,"length":12},"newText":""},{"span":{"start":14938,"length":12},"newText":""},{"span":{"start":14988,"length":16},"newText":""},{"span":{"start":15074,"length":12},"newText":""},{"span":{"start":15120,"length":12},"newText":""},{"span":{"start":15166,"length":12},"newText":""},{"span":{"start":15212,"length":8},"newText":""},{"span":{"start":15246,"length":8},"newText":""},{"span":{"start":15283,"length":8},"newText":""},{"span":{"start":15316,"length":8},"newText":""},{"span":{"start":15347,"length":4},"newText":""},{"span":{"start":15365,"length":4},"newText":""},{"span":{"start":15384,"length":4},"newText":""},{"span":{"start":21364,"length":0},"newText":"\n"},{"span":{"start":21401,"length":0},"newText":"\n "},{"span":{"start":21426,"length":0},"newText":"\n "},{"span":{"start":21429,"length":0},"newText":"\n"},{"span":{"start":21440,"length":2},"newText":""},{"span":{"start":21443,"length":0},"newText":"\n "},{"span":{"start":21470,"length":0},"newText":"\n "}] \ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/InitialDocument.txt new file mode 100644 index 00000000000..6362d5aa9b4 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/InitialDocument.txt @@ -0,0 +1,446 @@ +@page "/admin/gametrac" +@using Microsoft.AspNetCore.Authorization +@using System.Net.Http.Json +@attribute [Authorize] +@inject HttpClient Http +@inject ServiceTrac.Client.Services.IClientAuthService AuthService +@inject ServiceTrac.Client.Services.ToastService ToastService +@implements IDisposable + +GameTrac Sync — Admin — ServiceTrac + +
+ +
+

+ GameTrac Sync Administration +

+ Admin Panel +
+ +
+ + @if (!isAuthorized) + { +
+ + Access Denied — You do not have permission to access this page. +
+ } + else + { + +
+ +
+
+
+ Sync Status +
+
+ @if (statusData == null && isLoading) + { +
+
+
+ } + else if (statusData != null) + { +
+ Sync Enabled: + @if (statusData.SyncEnabled) + { + Yes + } + else + { + No + } +
+ + @if (statusData.RecentLogs.Any()) + { + var last = statusData.RecentLogs.First(); +
Last Run: @last.SyncedOn.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")
+
+ Status: + @last.Status +
+
Records Read: @last.RecordsRead.ToString("N0")
+
Records Upserted: @last.RecordsUpserted.ToString("N0")
+ @if (last.WeekEndingFrom.HasValue && last.WeekEndingTo.HasValue) + { +
+ Week Range: + @last.WeekEndingFrom.Value.ToString("MM/dd/yy") — @last.WeekEndingTo.Value.ToString("MM/dd/yy") +
+ } +
Triggered By: @(last.TriggeredBy ?? "—")
+ + @if (!string.IsNullOrEmpty(last.ErrorMessage)) + { +
+ @last.ErrorMessage +
+ } + } + else + { +
No sync runs recorded yet.
+ } + } +
+
+
+ + +
+
+
+ Manual Sync +
+
+

+ Trigger an immediate sync from the GameTrac .mdb file. + The scheduled sync also runs automatically every 4 hours. + Only one sync can run at a time. +

+
+
+ + +
+ @if (isSyncRunning && !isSyncing) + { +
+ Sync in progress… +
+ } + @if (!string.IsNullOrEmpty(syncMessage)) + { +
+ @syncMessage +
+ } +
+
+
+
+ + +
+
+
+ How Equipment Linking Works +
+
+
    +
  1. The sync pulls weekly data from the GameTrac .mdb and stores it in SQL Server indexed by IC Tag.
  2. +
  3. Go to Equipment → Edit on any machine.
  4. +
  5. Enter the machine's GameTrac IC Tag (e.g. 216000006A00) in the identifiers section.
  6. +
  7. Save — the Performance tab will now show weekly revenue charts for that machine.
  8. +
  9. The IC Tag value can be found in GameTrac's EquipmentIC table under the ICTag field.
  10. +
+
+
+
+
+ + +
+
+ Sync History (Last 10 Runs) + +
+
+ @if (isLoading && statusData == null) + { +
+
+
+ } + else if (statusData == null || !statusData.RecentLogs.Any()) + { +
+ + No sync runs recorded yet. Click Sync Now to run the first sync. +
+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (var log in statusData.RecentLogs) + { + + + + + + + + + + + } + +
Run #StartedStatusReadUpsertedWeek RangeTriggered ByError
#@log.SyncId@log.SyncedOn.ToLocalTime().ToString("MM/dd/yy h:mm tt")@log.Status@log.RecordsRead.ToString("N0")@log.RecordsUpserted.ToString("N0") + @if (log.WeekEndingFrom.HasValue && log.WeekEndingTo.HasValue) + { + @($"{log.WeekEndingFrom.Value:MM/dd/yy} → {log.WeekEndingTo.Value:MM/dd/yy}") + } + else + { + + } + @(log.TriggeredBy ?? "—") + @if (!string.IsNullOrEmpty(log.ErrorMessage)) + { + + + @(log.ErrorMessage.Length > 40 ? log.ErrorMessage[..40] + "…" : log.ErrorMessage) + + } + else + { + + } +
+
+ } +
+
+ } +
+
+ +@code { + private bool isAuthorized = false; + private bool isLoading = false; + private bool isSyncing = false; + private bool isFullResync = false; + private bool isSyncRunning = false; + private bool syncSuccess = false; + private string syncMessage = ""; + private string currentUsername = ""; + + private ApiGameTracSyncStatusDto? statusData = null; + + private System.Threading.Timer? _refreshTimer; + + protected override async Task OnInitializedAsync() + { + var user = await AuthService.GetCurrentUserAsync(); + currentUsername = user?.Username ?? ""; + isAuthorized = user?.RoleId >= 500; + + if (isAuthorized) + await LoadStatus(); + } + + private async Task LoadStatus() + { + isLoading = true; + StateHasChanged(); + try + { + var response = await Http.GetAsync("api/gametrac/syncstatus"); + if (response.IsSuccessStatusCode) + statusData = await response.Content.ReadFromJsonAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[GAMETRAC ADMIN] LoadStatus error: {ex.Message}"); + } + finally + { + isLoading = false; + + // If a sync is still Running, poll every 5 seconds + var wasRunning = isSyncRunning; + isSyncRunning = statusData?.RecentLogs.FirstOrDefault()?.Status == "Running"; + if (isSyncRunning) + { + StartPolling(); + } + else + { + StopPolling(); + // If we just transitioned from Running → done, show a toast + if (wasRunning) + { + var last = statusData?.RecentLogs.FirstOrDefault(); + if (last?.Status == "Success") + { + syncMessage = $"Sync complete — {last.RecordsUpserted:N0} records upserted."; + syncSuccess = true; + ToastService.ShowSuccess("GameTrac Sync", syncMessage); + } + else if (last?.Status == "Failed") + { + syncMessage = $"Sync failed: {last.ErrorMessage}"; + syncSuccess = false; + ToastService.ShowError("GameTrac Sync Failed", last.ErrorMessage ?? "Unknown error"); + } + } + } + + StateHasChanged(); + } + } + + private async Task TriggerSync(bool fullResync = false) + { + isSyncing = true; + isFullResync = fullResync; + syncMessage = ""; + StateHasChanged(); + try + { + var url = fullResync ? "api/gametrac/sync?fullResync=true" : "api/gametrac/sync"; + var response = await Http.PostAsync(url, null); + + if (response.StatusCode == System.Net.HttpStatusCode.Accepted) + { + // 202 — sync started in background, poll for completion + syncSuccess = true; + syncMessage = fullResync + ? "Full resync started — reading all records. This may take a few minutes…" + : "Sync started — polling for completion…"; + isSyncRunning = true; + StartPolling(); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Conflict) + { + syncSuccess = false; + syncMessage = "A sync is already running. Watch the table below for progress."; + isSyncRunning = true; + StartPolling(); + } + else if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + syncSuccess = false; + syncMessage = "Access denied — admin privileges required."; + } + else + { + syncSuccess = false; + syncMessage = $"Unexpected response: {(int)response.StatusCode}"; + } + } + catch (Exception ex) + { + syncSuccess = false; + syncMessage = $"Error: {ex.Message}"; + } + finally + { + isSyncing = false; + StateHasChanged(); + } + } + + private void StartPolling() + { + StopPolling(); + _refreshTimer = new System.Threading.Timer(async _ => + { + await InvokeAsync(async () => + { + await LoadStatus(); + if (!isSyncRunning) StopPolling(); + }); + }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + + private void StopPolling() + { + _refreshTimer?.Dispose(); + _refreshTimer = null; + } + + public void Dispose() => StopPolling(); + + private static string StatusBadgeClass(string status) => status switch + { + "Success" => "bg-success", + "Failed" => "bg-danger", + "Running" => "bg-info text-dark", + "Partial" => "bg-warning text-dark", + _ => "bg-secondary" + }; + + // ── DTOs ───────────────────────────────────────────────────────────────────── + + public class ApiGameTracSyncLogDto + { + public int SyncId { get; set; } + public DateTime SyncedOn { get; set; } + public DateTime? WeekEndingFrom { get; set; } + public DateTime? WeekEndingTo { get; set; } + public int RecordsRead { get; set; } + public int RecordsUpserted { get; set; } + public string Status { get; set; } = string.Empty; + public string? ErrorMessage { get; set; } + public string? TriggeredBy { get; set; } + } + + public class ApiGameTracSyncStatusDto + { + public List RecentLogs { get; set; } = new(); + public bool SyncEnabled { get; set; } + } +} + + diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/Options.json b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/Options.json new file mode 100644 index 00000000000..899c93ef3de --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/Options.json @@ -0,0 +1 @@ +{"InsertSpaces":true,"TabSize":4,"CodeBlockBraceOnNextLine":false,"AttributeIndentStyle":0,"CSharpSyntaxFormattingOptions":{"Spacing":919680,"SpacingAroundBinaryOperator":0,"NewLines":32767,"LabelPositioning":1,"Indentation":30,"WrappingKeepStatementsOnSingleLine":true,"WrappingPreserveSingleLine":true,"NamespaceDeclarations":0,"PreferTopLevelStatements":true,"CollectionExpressionWrappingLength":120}} \ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/Range.json b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/Range.json new file mode 100644 index 00000000000..ec747fa47dd --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/GameTracAdmin/Range.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MixedIndentation/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MixedIndentation/InitialDocument.txt index b598265d66b..50834c4e791 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MixedIndentation/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MixedIndentation/InitialDocument.txt @@ -1,10 +1,10 @@ -
-@switch (true) -{ - case true: - @if (true) - { - } - break; -} +
+@switch (true) +{ + case true: + @if (true) + { + } + break; +}
\ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MultiLineLambda/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MultiLineLambda/InitialDocument.txt index 4076c3a8faa..a4fdc322f0c 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MultiLineLambda/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/MultiLineLambda/InitialDocument.txt @@ -1,3 +1,3 @@ - + diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RanOutOfOriginalLines/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RanOutOfOriginalLines/InitialDocument.txt index 2e5a813eecc..583ed8be962 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RanOutOfOriginalLines/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RanOutOfOriginalLines/InitialDocument.txt @@ -1,52 +1,52 @@ - - - - - - @ViewData["Title"] - razor124 - - - - - - -
- -
-
-
- @RenderBody() -
-
- -
-
- © 2026 - razor124 - Privacy -
-
- - - - - - @await RenderSectionAsync("Scripts", required: false) - - + + + + + + @ViewData["Title"] - razor124 + + + + + + +
+ +
+
+
+ @RenderBody() +
+
+ +
+
+ © 2026 - razor124 - Privacy +
+
+ + + + + + @await RenderSectionAsync("Scripts", required: false) + + diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RealWorldMixedIndentation/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RealWorldMixedIndentation/InitialDocument.txt index 21445a571e4..a95a708147e 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RealWorldMixedIndentation/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/RealWorldMixedIndentation/InitialDocument.txt @@ -1,354 +1,354 @@ -@using App.Models.Feedback -@using App.Models.Surveys -@using App.Client.Web.Pages.Surveys.Forms.Components.FormViewer - -@inject IJSRuntime JS -@inject HttpClient Http -@inject ILogger Logger - - - -
-

@AttemptModel.Attempt.Recipient.FullName

-

@(AttemptModel.Attempt.RecipientAddress is Attempt.PhoneNumber phoneNumber ? phoneNumber.Number : String.Empty)

-

@(AttemptModel.Attempt.StartedAt?.LocalDateTime.ToString("F"))

- @if (AttemptModel.Attempt.IsRecordingAvailable) - { - - } -
-
- - - - - -
-
-
-
- @switch (_selectedAttemptDetail) - { - case AttemptDetail.Details: -
- @if (AttemptModel.Attempt.StartedAt.HasValue) - { -
- -

@AttemptModel.Attempt.StartedAt.Value.LocalDateTime.ToString("F")

-
- } - - @if (AttemptModel.Attempt is CallAttempt callAttempt && callAttempt.EndedAt.HasValue) - { -
- -

@callAttempt.EndedAt.Value.LocalDateTime.ToString("F")

-
- - @if (AttemptModel.Attempt.StartedAt.HasValue) - { -
- -

@((callAttempt.EndedAt.Value - AttemptModel.Attempt.StartedAt.Value).ToString(@"hh\:mm\:ss"))

-
- } - } - - @if (AttemptModel.Attempt is CallAttempt { OutboundPhoneNumber: not null } callAttemptWithNumber) - { - - } - -
- -

- @if (AttemptModel.Execution is PhoneCallCampaignExecution phoneExecution) - { - - @phoneExecution.RecipientListHeader.DisplayName - - } - else if (AttemptModel.Execution is ManualCampaignExecution manualExecution) - { - - @manualExecution.RecipientListHeader.DisplayName - - } - else if (AttemptModel.Execution is StubCampaignExecution stubExecution) - { - - @stubExecution.RecipientListHeader.DisplayName - - } - else - { - Címzett lista nem érhető el - } -

-
- -
- -

@(AttemptModel.Attempt.Recipient.FullName ?? "Nincs megadva")

-
- - @if (AttemptModel.Attempt.RecipientAddress is Attempt.PhoneNumber phoneAddress) - { -
- -

- - @phoneAddress.Number - -

-
- } - - @if (AttemptModel.Attempt.Recipient.Dimensions?.Any() == true) - { -
- -
- @foreach (var dimension in AttemptModel.Attempt.Recipient.Dimensions) - { -

@dimension.Key: @dimension.Value

- } -
-
- } - - @if (!string.IsNullOrWhiteSpace(AttemptModel.Attempt.Recipient.FreeFormAddress)) - { -
- -

@AttemptModel.Attempt.Recipient.FreeFormAddress

-
- } - -
- -

@AttemptModel.Attempt.Id

-
- - @if (!AttemptModel.Attempt.CallProviderCallId.IsNullOrEmpty()) - { -
- -

@AttemptModel.Attempt.CallProviderCallId

-
- } - - @if (!AttemptModel.Attempt.ExternalProviderCallId.IsNullOrEmpty()) - { -
- -

@AttemptModel.Attempt.ExternalProviderCallId

-
- } -
- break; - - case AttemptDetail.Transcript: - if (!AttemptModel.Attempt.IsTranscriptAvailable) break; - if (AttemptModel.Transcript is null) - { - if (_transcriptLoading) - { - - } - } - else - { - - } - - break; - - case AttemptDetail.Recording: - if (!AttemptModel.Attempt.IsRecordingAvailable) break; - -
- -
- break; - - case AttemptDetail.Answers: - if (AttemptModel.Attempt.Status is not AnsweredAttemptStatus) break; - - if (_surveySnapshot is null) - { - - } - else - { - - } - -
- - - @if (_isEditingAnswers && _formViewer.HasAnyChanges) - { - - } -
- break; - - case AttemptDetail.Statuses: - @if (_attemptStatuses is { } attemptStatuses) - { - - - - - - - - - @foreach (var status in attemptStatuses) - { - - - - - } - -
IdőÁllapot
@status.Timestamp.LocalDateTime.ToString() - @switch (status.Value) - { - case DeclinedAttemptStatus { Reason: string reason }: - Visszautasítva: - @reason - break; - - case RescheduledAttemptStatus { RescheduledTo: DateTimeOffset rescheduledTo }: - Átütemezve: - @rescheduledTo.LocalDateTime - break; - - case OutboundAddressConcurrencyLimitReachedAttemptStatus mceas: - Egyidejű korlát elérve: - @mceas.OutboundAddress - break; - - case OutboundAddressUnavailableAttemptStatus mceas: - Kimenő kapcsolat nem elérhető: - @mceas.OutboundAddress - break; - - case ModelConfigurationErrorAttemptStatus mceas: - Konfigurációs hiba: - @mceas.ProviderErrorCode @mceas.ProviderErrorMessage - break; - - case CallProviderErrorAttemptStatus mceas: - Hívás szolgáltató hiba: - @mceas.ProviderErrorCode @mceas.ProviderErrorMessage - break; - - case InvalidAddressAttemptStatus mceas: - Hibás cím: - @mceas.Address @mceas.ProviderErrorCode @mceas.ProviderErrorMessage - break; - - default: - @status.Value.ToDisplayString() - break; - } -
- } - else - { - - } - - break; - } -
- @if (_showFeedback) - { - - } - - @if (_showReevaluateDialog) - { - - } -
- - @if (AttemptModel.Attempt is { PreviousAttemptLink: { } previousLink }) - { - - } - @if (AttemptModel.Attempt is { NextAttemptLink: { } nextLink }) - { - - } - @if (AttemptModel is { Attempt.IsTranscriptAvailable: true } or { Attempt.IsRecordingAvailable: true }) - { - - } - +@using App.Models.Feedback +@using App.Models.Surveys +@using App.Client.Web.Pages.Surveys.Forms.Components.FormViewer + +@inject IJSRuntime JS +@inject HttpClient Http +@inject ILogger Logger + + + +
+

@AttemptModel.Attempt.Recipient.FullName

+

@(AttemptModel.Attempt.RecipientAddress is Attempt.PhoneNumber phoneNumber ? phoneNumber.Number : String.Empty)

+

@(AttemptModel.Attempt.StartedAt?.LocalDateTime.ToString("F"))

+ @if (AttemptModel.Attempt.IsRecordingAvailable) + { + + } +
+
+ + + + + +
+
+
+
+ @switch (_selectedAttemptDetail) + { + case AttemptDetail.Details: +
+ @if (AttemptModel.Attempt.StartedAt.HasValue) + { +
+ +

@AttemptModel.Attempt.StartedAt.Value.LocalDateTime.ToString("F")

+
+ } + + @if (AttemptModel.Attempt is CallAttempt callAttempt && callAttempt.EndedAt.HasValue) + { +
+ +

@callAttempt.EndedAt.Value.LocalDateTime.ToString("F")

+
+ + @if (AttemptModel.Attempt.StartedAt.HasValue) + { +
+ +

@((callAttempt.EndedAt.Value - AttemptModel.Attempt.StartedAt.Value).ToString(@"hh\:mm\:ss"))

+
+ } + } + + @if (AttemptModel.Attempt is CallAttempt { OutboundPhoneNumber: not null } callAttemptWithNumber) + { + + } + +
+ +

+ @if (AttemptModel.Execution is PhoneCallCampaignExecution phoneExecution) + { + + @phoneExecution.RecipientListHeader.DisplayName + + } + else if (AttemptModel.Execution is ManualCampaignExecution manualExecution) + { + + @manualExecution.RecipientListHeader.DisplayName + + } + else if (AttemptModel.Execution is StubCampaignExecution stubExecution) + { + + @stubExecution.RecipientListHeader.DisplayName + + } + else + { + Címzett lista nem érhető el + } +

+
+ +
+ +

@(AttemptModel.Attempt.Recipient.FullName ?? "Nincs megadva")

+
+ + @if (AttemptModel.Attempt.RecipientAddress is Attempt.PhoneNumber phoneAddress) + { +
+ +

+ + @phoneAddress.Number + +

+
+ } + + @if (AttemptModel.Attempt.Recipient.Dimensions?.Any() == true) + { +
+ +
+ @foreach (var dimension in AttemptModel.Attempt.Recipient.Dimensions) + { +

@dimension.Key: @dimension.Value

+ } +
+
+ } + + @if (!string.IsNullOrWhiteSpace(AttemptModel.Attempt.Recipient.FreeFormAddress)) + { +
+ +

@AttemptModel.Attempt.Recipient.FreeFormAddress

+
+ } + +
+ +

@AttemptModel.Attempt.Id

+
+ + @if (!AttemptModel.Attempt.CallProviderCallId.IsNullOrEmpty()) + { +
+ +

@AttemptModel.Attempt.CallProviderCallId

+
+ } + + @if (!AttemptModel.Attempt.ExternalProviderCallId.IsNullOrEmpty()) + { +
+ +

@AttemptModel.Attempt.ExternalProviderCallId

+
+ } +
+ break; + + case AttemptDetail.Transcript: + if (!AttemptModel.Attempt.IsTranscriptAvailable) break; + if (AttemptModel.Transcript is null) + { + if (_transcriptLoading) + { + + } + } + else + { + + } + + break; + + case AttemptDetail.Recording: + if (!AttemptModel.Attempt.IsRecordingAvailable) break; + +
+ +
+ break; + + case AttemptDetail.Answers: + if (AttemptModel.Attempt.Status is not AnsweredAttemptStatus) break; + + if (_surveySnapshot is null) + { + + } + else + { + + } + +
+ + + @if (_isEditingAnswers && _formViewer.HasAnyChanges) + { + + } +
+ break; + + case AttemptDetail.Statuses: + @if (_attemptStatuses is { } attemptStatuses) + { + + + + + + + + + @foreach (var status in attemptStatuses) + { + + + + + } + +
IdőÁllapot
@status.Timestamp.LocalDateTime.ToString() + @switch (status.Value) + { + case DeclinedAttemptStatus { Reason: string reason }: + Visszautasítva: + @reason + break; + + case RescheduledAttemptStatus { RescheduledTo: DateTimeOffset rescheduledTo }: + Átütemezve: + @rescheduledTo.LocalDateTime + break; + + case OutboundAddressConcurrencyLimitReachedAttemptStatus mceas: + Egyidejű korlát elérve: + @mceas.OutboundAddress + break; + + case OutboundAddressUnavailableAttemptStatus mceas: + Kimenő kapcsolat nem elérhető: + @mceas.OutboundAddress + break; + + case ModelConfigurationErrorAttemptStatus mceas: + Konfigurációs hiba: + @mceas.ProviderErrorCode @mceas.ProviderErrorMessage + break; + + case CallProviderErrorAttemptStatus mceas: + Hívás szolgáltató hiba: + @mceas.ProviderErrorCode @mceas.ProviderErrorMessage + break; + + case InvalidAddressAttemptStatus mceas: + Hibás cím: + @mceas.Address @mceas.ProviderErrorCode @mceas.ProviderErrorMessage + break; + + default: + @status.Value.ToDisplayString() + break; + } +
+ } + else + { + + } + + break; + } +
+ @if (_showFeedback) + { + + } + + @if (_showReevaluateDialog) + { + + } +
+ + @if (AttemptModel.Attempt is { PreviousAttemptLink: { } previousLink }) + { + + } + @if (AttemptModel.Attempt is { NextAttemptLink: { } nextLink }) + { + + } + @if (AttemptModel is { Attempt.IsTranscriptAvailable: true } or { Attempt.IsRecordingAvailable: true }) + { + + } +
\ No newline at end of file diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/UnexpectedFalseInIndentBlockOperation/InitialDocument.txt b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/UnexpectedFalseInIndentBlockOperation/InitialDocument.txt index aeb7b9b10cb..aedabe7304e 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/UnexpectedFalseInIndentBlockOperation/InitialDocument.txt +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/TestFiles/FormattingLog/UnexpectedFalseInIndentBlockOperation/InitialDocument.txt @@ -1,152 +1,152 @@ -@page -@using netlandingpage.Helpers; -@using Microsoft.Extensions.Configuration; -@inject IConfiguration Configuration; -@inject OperatingSystemManager OperatingSystemManager; - -@{ - ViewData["BodyCssClass"] = "refresh"; - ViewData["Title"] = S["Doggie Bus: how one developer built an \"Uber for Dogs\" with .NET MAUI and Azure | .NET"]; - ViewData["Description"] = S["Doggie Bus is a New Orleans startup founded by Troy Bergeron, offering a unique Uber for dogs service – an app that lets pet owners schedule safe, convenient rides for their dogs."]; - this.Breadcrumb("Doggie Bus", (S["Home"], "/"), (S["Customers"], "/platform/customers")); -} - -@section styles { - -} - -
-
- -
-

@T[

-
-
-

@T["Industry"]

-

@T["Transportation"]

-

@T["Organization Size"]

-

@T["Small (1-100 employees)"]

-

@T["Country/region"]

-

@T["United States"]

-

- @T["Technology"]
-

- .NET MAUI - ASP.NET - Azure - Azure SignalR - Entity Framework -
-

-
- -
-

@T["Company"]

-

Doggie Bus

-

@T["Doggie Bus is a New Orleans startup founded by Troy Bergeron, offering a unique \"Uber for dogs\" service – a mobile app that lets pet owners schedule safe, convenient rides for their dogs to daycare, the vet, and more. Troy, a lifelong dog lover, personally drives the Doggie Bus shuttle and treats every pup like family. His goal is to make pet transport as easy and trustworthy as hailing a rideshare, so owners have total peace of mind."]

-
- -
-
- -
-
-

@T["To bring this vision to life, Troy teamed up with Mario DeLuca (DeLuca Technologies), a veteran software engineer and early adopter of .NET MAUI (and Xamarin before that). Together they built a cross-platform mobile app that rivals top rideshare experiences but is tailored for pet parents."]

-
-
- @T[ -
-
- @T[ -
-
- @T[ -
-
-

@T["Real people, real passion"]

-

@T["In early 2024, Doggie Bus launched on iOS and Android, making it a breeze for pet owners to arrange rides. Troy's personal commitment to pet safety is at the heart of the service. He meets every new client (human and dog) and provides trust through transparency: owners can track the Doggie Bus in real time on a map and get notifications at each pickup or drop-off."]

-

@T["Pet parents, who are often as protective as any parent, love this visibility. The app gives them peace of mind, knowing where their \"fur baby\" is at all times and that they're in caring hands."]

-

@T["On the tech side, Mario was the behind-the-scenes hero. As a long-time C# developer, he was thrilled to use .NET end-to-end for Doggie Bus. This allowed him to single-handedly create a polished, native mobile experience on both platforms. The partnership worked seamlessly: Troy contributed deep domain insight and constant feedback as the first user of the app (running his daily route via an admin mode), while Mario iterated quickly on features. Both were driven by the mission to make the service \"so easy a dog could do it,\" as Troy likes to joke."]

-
- - - - - - - - - -
-

@T["Our service should be so easy, a dog could do it.\""]

-
@T["Troy Bergeron, founder, pet parent & driver"] Doggie Bus
-
-
-
- -
-

@T["Better together - solving it with .NET MAUI"]

-

@T["The Challenge: Doggie Bus needed high-quality mobile apps on two platforms without double the effort. They also required real-time communication (for live ride tracking) and a slick, user-friendly UI to instill confidence in users. As a small startup, they had to deliver all this with limited time and budget."]

-

@T["The .NET Solution: Mario chose .NET MAUI for the mobile app, enabling him to target iOS and Android from a single C# codebase. This decision immediately cut development time and cost in half, since one codebase produces two native apps. \"With .NET MAUI, we were able to share over 90% of our code across platforms. That efficiency let us move faster, reduce maintenance overhead, and deliver a consistent experience on both iOS and Android.\", Mario adds. The .NET MAUI framework provided the native performance and flexibility needed, without requiring a large team. Key aspects of the solution include:"]

-
    -
  • @T["Unified App Codebase: ~90% of the Doggie Bus app code is shared across platforms. Core features (UI layouts, ride logic, data models) were written once in .NET and run natively on both iOS and Android. Only a few parts required platform-specific tweaks (for example, a custom map renderer on each OS to smoothly animate the little bus icon). This single-codebase approach ensures feature parity and simplified maintenance."]
  • -
  • @T["Azure-Powered Backend: The team built a cloud backend with ASP.NET Core and Azure to handle authentication, scheduling, and data storage. Using Azure SignalR, the app achieves real-time updates: as Troy drives, the van's GPS location is sent to the cloud and instantly pushed to pet owners' phones, so they see the bus moving live on the map. All data (schedules, pet profiles, vaccination records) is stored in Azure SQL Database via Entity Framework. The backend also integrates with Apple and Google for easy sign-in. This end-to-end Microsoft stack (MAUI app + Azure services) ensures reliability and scalability from day one."]
  • -
  • @T["Modern Dev Tools & Libraries: Mario leveraged the rich .NET ecosystem to accelerate development. He used the .NET MAUI Community Toolkit for ready-made UI components and effects and tapped into open-source libraries for things like authentication flows. Productivity features like XAML Hot Reload allowed rapid UI tweaks, and GitHub Copilot acted as an AI pair-programmer, generating boilerplate code and speeding up development. The result: Doggie Bus went from concept to feature-rich, polished app in a fraction of the time compared to traditional multi-team development."]
  • -
-

@T["Why .NET MAUI? Aside from cross-platform efficiency, Doggie Bus chose .NET for its performance and unified ecosystem. The app uses ahead-of-time (AOT) compilation, so it runs with \"buttery smooth\" performance even on older Android phones. By using C# on both client and server, the team can share code and skills across the whole project. For example, data models are defined once and reused on both ends, reducing bugs and mismatches."]

-

@T["Mario briefly considered other frameworks, but having delivered successful apps with Xamarin, he trusted .NET MAUI to give native-quality results. \"In my opinion, it's a no-brainer,\" he says about choosing MAUI. This unified approach eliminated hiring separate iOS/Android developers and learning new languages - a huge advantage for a small company. Even challenges like implementing real-time maps and social login were solved smoothly with .NET's flexibility and libraries."]

-

@T["Whenever a hurdle arose (like fine-tuning the moving map pin animation), .NET allowed custom solutions without hitting a dead end. In short, .NET provides everything needed in one platform, making development faster, cheaper, and more enjoyable for the Doggie Bus team."]

-
- -
-
- - - - - - - - - -
-

@T[".NET MAUI helped us reduce development costs by over 50%. With a single codebase and shared backend logic, we delivered high performance native apps for both iOS and Android—without doubling the work.\""]

-
@T["Mario DeLuca, CEO"] DeLuca Technologies
-
-
-
- -
-

@T["Impact and the road ahead"]

-

@T["Delighting Pet Owners: Since launch, Doggie Bus has transformed how customers manage pet transportation. Booking a ride now takes just seconds in the app, replacing what used to be phone calls or texts. With a couple of taps, a pet owner schedules a pickup - no paperwork or back-and-forth needed. The app sends automatic notifications at key moments (when the bus is approaching, when your dog is picked up, and when drop-off is complete), so owners never worry or wonder."]

-

@T["They especially love the live tracking: watching the Doggie Bus icon move on the map in real time is both reassuring and fun. Troy notes, \"I get a lot of compliments on the notifications,\" and many users have told him the service is incredibly easy to use. This convenience and transparency have driven strong adoption by local pet owners. Many clients now book Doggie Bus rides multiple times a week as part of their routine, confident that it's dependable and safe for their pups."]

-

@T["For Troy's operation, the .NET solution brought immediate improvements. All scheduling and record-keeping became 100% digital - \"everything's in one app, there's not a paper trail,\" as Troy puts it. No more clipboards or manual logs; the app's admin features let him manage each day's route, check dog profiles and vaccination records, and handle payments all in one place."]

-

@T["This has streamlined operations and reduced errors. Troy can focus on caring for the dogs instead of paperwork. Financially, using .NET saved the company a fortune in development costs. Building separate native apps would have required two developers or expensive outsourcing, which was beyond reach. Instead, one developer delivered the entire product."]

-

@T["Mario estimates that choosing .NET MAUI \"cut development costs drastically,\" which was critical for Doggie Bus's launch. Despite a lean budget, the final app achieved polished, professional quality on par with far larger competitors. The technology choice also simplified future maintenance: new features can be added once and appear on both platforms, keeping ongoing costs low."]

-

@T["Thanks to its solid tech foundation, Doggie Bus is ready to scale up. The cloud-native architecture on Azure can easily support more vehicles or new locations, aligning with Troy's plans to franchise the service. The team is already exploring expansion to other cities, knowing the same app and backend can be extended with minimal changes. They're also planning a web portal for bookings (likely built with Blazor WebAssembly), and much of the existing .NET code can be reused for it. With .NET, expansion is built-in, not an afterthought - the platform's versatility means mobile, web, and future platforms can all share one codebase and skillset."]

-

@T["In reflecting on the journey so far, Troy and Mario emphasize how .NET empowered them to turn an idea into reality. \"Mario made my dream come true,\" says Troy, grateful for the technology and talent that brought Doggie Bus to life. Mario, in turn, credits the tools: \".NET makes dreams come true,\" he says, noting that the platform enabled a small team to deliver an app beyond their initial expectations. Mario continues: \"This isn't just a mobile app — it's a scalable, cloud connected platform built for growth. With .NET MAUI and Azure, we created a future ready foundation that's lean, efficient, and designed to expand.\". The success of Doggie Bus - happy pet owners, a thriving business, and a foundation for growth - stands as proof. With .NET in the driver's seat, this \"Uber for dogs\" is hitting the road with confidence, and the journey is just getting started."]

-
- -
-
- - - - - - - - - -
-

@T["With .NET and Microsoft, you're not just building software — you're turning your vision into real world solutions. It's where dreams become reality.\""]

-
@T["Mario DeLuca, CEO"] DeLuca Technologies
-
-
-
-
-
-
+@page +@using netlandingpage.Helpers; +@using Microsoft.Extensions.Configuration; +@inject IConfiguration Configuration; +@inject OperatingSystemManager OperatingSystemManager; + +@{ + ViewData["BodyCssClass"] = "refresh"; + ViewData["Title"] = S["Doggie Bus: how one developer built an \"Uber for Dogs\" with .NET MAUI and Azure | .NET"]; + ViewData["Description"] = S["Doggie Bus is a New Orleans startup founded by Troy Bergeron, offering a unique Uber for dogs service – an app that lets pet owners schedule safe, convenient rides for their dogs."]; + this.Breadcrumb("Doggie Bus", (S["Home"], "/"), (S["Customers"], "/platform/customers")); +} + +@section styles { + +} + +
+
+ +
+

@T[

+
+
+

@T["Industry"]

+

@T["Transportation"]

+

@T["Organization Size"]

+

@T["Small (1-100 employees)"]

+

@T["Country/region"]

+

@T["United States"]

+

+ @T["Technology"]
+

+ .NET MAUI + ASP.NET + Azure + Azure SignalR + Entity Framework +
+

+
+ +
+

@T["Company"]

+

Doggie Bus

+

@T["Doggie Bus is a New Orleans startup founded by Troy Bergeron, offering a unique \"Uber for dogs\" service – a mobile app that lets pet owners schedule safe, convenient rides for their dogs to daycare, the vet, and more. Troy, a lifelong dog lover, personally drives the Doggie Bus shuttle and treats every pup like family. His goal is to make pet transport as easy and trustworthy as hailing a rideshare, so owners have total peace of mind."]

+
+ +
+
+ +
+
+

@T["To bring this vision to life, Troy teamed up with Mario DeLuca (DeLuca Technologies), a veteran software engineer and early adopter of .NET MAUI (and Xamarin before that). Together they built a cross-platform mobile app that rivals top rideshare experiences but is tailored for pet parents."]

+
+
+ @T[ +
+
+ @T[ +
+
+ @T[ +
+
+

@T["Real people, real passion"]

+

@T["In early 2024, Doggie Bus launched on iOS and Android, making it a breeze for pet owners to arrange rides. Troy's personal commitment to pet safety is at the heart of the service. He meets every new client (human and dog) and provides trust through transparency: owners can track the Doggie Bus in real time on a map and get notifications at each pickup or drop-off."]

+

@T["Pet parents, who are often as protective as any parent, love this visibility. The app gives them peace of mind, knowing where their \"fur baby\" is at all times and that they're in caring hands."]

+

@T["On the tech side, Mario was the behind-the-scenes hero. As a long-time C# developer, he was thrilled to use .NET end-to-end for Doggie Bus. This allowed him to single-handedly create a polished, native mobile experience on both platforms. The partnership worked seamlessly: Troy contributed deep domain insight and constant feedback as the first user of the app (running his daily route via an admin mode), while Mario iterated quickly on features. Both were driven by the mission to make the service \"so easy a dog could do it,\" as Troy likes to joke."]

+
+ + + + + + + + + +
+

@T["Our service should be so easy, a dog could do it.\""]

+
@T["Troy Bergeron, founder, pet parent & driver"] Doggie Bus
+
+
+
+ +
+

@T["Better together - solving it with .NET MAUI"]

+

@T["The Challenge: Doggie Bus needed high-quality mobile apps on two platforms without double the effort. They also required real-time communication (for live ride tracking) and a slick, user-friendly UI to instill confidence in users. As a small startup, they had to deliver all this with limited time and budget."]

+

@T["The .NET Solution: Mario chose .NET MAUI for the mobile app, enabling him to target iOS and Android from a single C# codebase. This decision immediately cut development time and cost in half, since one codebase produces two native apps. \"With .NET MAUI, we were able to share over 90% of our code across platforms. That efficiency let us move faster, reduce maintenance overhead, and deliver a consistent experience on both iOS and Android.\", Mario adds. The .NET MAUI framework provided the native performance and flexibility needed, without requiring a large team. Key aspects of the solution include:"]

+
    +
  • @T["Unified App Codebase: ~90% of the Doggie Bus app code is shared across platforms. Core features (UI layouts, ride logic, data models) were written once in .NET and run natively on both iOS and Android. Only a few parts required platform-specific tweaks (for example, a custom map renderer on each OS to smoothly animate the little bus icon). This single-codebase approach ensures feature parity and simplified maintenance."]
  • +
  • @T["Azure-Powered Backend: The team built a cloud backend with ASP.NET Core and Azure to handle authentication, scheduling, and data storage. Using Azure SignalR, the app achieves real-time updates: as Troy drives, the van's GPS location is sent to the cloud and instantly pushed to pet owners' phones, so they see the bus moving live on the map. All data (schedules, pet profiles, vaccination records) is stored in Azure SQL Database via Entity Framework. The backend also integrates with Apple and Google for easy sign-in. This end-to-end Microsoft stack (MAUI app + Azure services) ensures reliability and scalability from day one."]
  • +
  • @T["Modern Dev Tools & Libraries: Mario leveraged the rich .NET ecosystem to accelerate development. He used the .NET MAUI Community Toolkit for ready-made UI components and effects and tapped into open-source libraries for things like authentication flows. Productivity features like XAML Hot Reload allowed rapid UI tweaks, and GitHub Copilot acted as an AI pair-programmer, generating boilerplate code and speeding up development. The result: Doggie Bus went from concept to feature-rich, polished app in a fraction of the time compared to traditional multi-team development."]
  • +
+

@T["Why .NET MAUI? Aside from cross-platform efficiency, Doggie Bus chose .NET for its performance and unified ecosystem. The app uses ahead-of-time (AOT) compilation, so it runs with \"buttery smooth\" performance even on older Android phones. By using C# on both client and server, the team can share code and skills across the whole project. For example, data models are defined once and reused on both ends, reducing bugs and mismatches."]

+

@T["Mario briefly considered other frameworks, but having delivered successful apps with Xamarin, he trusted .NET MAUI to give native-quality results. \"In my opinion, it's a no-brainer,\" he says about choosing MAUI. This unified approach eliminated hiring separate iOS/Android developers and learning new languages - a huge advantage for a small company. Even challenges like implementing real-time maps and social login were solved smoothly with .NET's flexibility and libraries."]

+

@T["Whenever a hurdle arose (like fine-tuning the moving map pin animation), .NET allowed custom solutions without hitting a dead end. In short, .NET provides everything needed in one platform, making development faster, cheaper, and more enjoyable for the Doggie Bus team."]

+
+ +
+
+ + + + + + + + + +
+

@T[".NET MAUI helped us reduce development costs by over 50%. With a single codebase and shared backend logic, we delivered high performance native apps for both iOS and Android—without doubling the work.\""]

+
@T["Mario DeLuca, CEO"] DeLuca Technologies
+
+
+
+ +
+

@T["Impact and the road ahead"]

+

@T["Delighting Pet Owners: Since launch, Doggie Bus has transformed how customers manage pet transportation. Booking a ride now takes just seconds in the app, replacing what used to be phone calls or texts. With a couple of taps, a pet owner schedules a pickup - no paperwork or back-and-forth needed. The app sends automatic notifications at key moments (when the bus is approaching, when your dog is picked up, and when drop-off is complete), so owners never worry or wonder."]

+

@T["They especially love the live tracking: watching the Doggie Bus icon move on the map in real time is both reassuring and fun. Troy notes, \"I get a lot of compliments on the notifications,\" and many users have told him the service is incredibly easy to use. This convenience and transparency have driven strong adoption by local pet owners. Many clients now book Doggie Bus rides multiple times a week as part of their routine, confident that it's dependable and safe for their pups."]

+

@T["For Troy's operation, the .NET solution brought immediate improvements. All scheduling and record-keeping became 100% digital - \"everything's in one app, there's not a paper trail,\" as Troy puts it. No more clipboards or manual logs; the app's admin features let him manage each day's route, check dog profiles and vaccination records, and handle payments all in one place."]

+

@T["This has streamlined operations and reduced errors. Troy can focus on caring for the dogs instead of paperwork. Financially, using .NET saved the company a fortune in development costs. Building separate native apps would have required two developers or expensive outsourcing, which was beyond reach. Instead, one developer delivered the entire product."]

+

@T["Mario estimates that choosing .NET MAUI \"cut development costs drastically,\" which was critical for Doggie Bus's launch. Despite a lean budget, the final app achieved polished, professional quality on par with far larger competitors. The technology choice also simplified future maintenance: new features can be added once and appear on both platforms, keeping ongoing costs low."]

+

@T["Thanks to its solid tech foundation, Doggie Bus is ready to scale up. The cloud-native architecture on Azure can easily support more vehicles or new locations, aligning with Troy's plans to franchise the service. The team is already exploring expansion to other cities, knowing the same app and backend can be extended with minimal changes. They're also planning a web portal for bookings (likely built with Blazor WebAssembly), and much of the existing .NET code can be reused for it. With .NET, expansion is built-in, not an afterthought - the platform's versatility means mobile, web, and future platforms can all share one codebase and skillset."]

+

@T["In reflecting on the journey so far, Troy and Mario emphasize how .NET empowered them to turn an idea into reality. \"Mario made my dream come true,\" says Troy, grateful for the technology and talent that brought Doggie Bus to life. Mario, in turn, credits the tools: \".NET makes dreams come true,\" he says, noting that the platform enabled a small team to deliver an app beyond their initial expectations. Mario continues: \"This isn't just a mobile app — it's a scalable, cloud connected platform built for growth. With .NET MAUI and Azure, we created a future ready foundation that's lean, efficient, and designed to expand.\". The success of Doggie Bus - happy pet owners, a thriving business, and a foundation for growth - stands as proof. With .NET in the driver's seat, this \"Uber for dogs\" is hitting the road with confidence, and the journey is just getting started."]

+
+ +
+
+ + + + + + + + + +
+

@T["With .NET and Microsoft, you're not just building software — you're turning your vision into real world solutions. It's where dreams become reality.\""]

+
@T["Mario DeLuca, CEO"] DeLuca Technologies
+
+
+
+
+
+
diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestFile.cs b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestFile.cs index 4e57c07010d..0ae314764c4 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestFile.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/TestFile.cs @@ -75,13 +75,15 @@ public async Task ReadAllTextAsync(CancellationToken cancellationToken) } } - public string ReadAllText() + public string ReadAllText(bool normalizeLineEndings = true) { using (var reader = new StreamReader(OpenRead())) { var contents = reader.ReadToEnd(); - return NormalizeContents(contents); + return normalizeLineEndings + ? NormalizeContents(contents) + : contents; } }