Files
ulapp/tests/FieldLogger.Tests/Sprint3UiContractTests.cs
2026-08-23 20:54:03 -05:00

207 lines
9.2 KiB
C#

using System.Text.RegularExpressions;
using System.Xml.Linq;
using Xunit;
namespace FieldLogger.Tests;
/// <summary>
/// Headless guardrails for the Sprint 3 MAUI screen integration. These deliberately inspect
/// XAML source so the QA gate can verify token use and accessibility basics without a MAUI
/// simulator or device workload.
/// </summary>
public sealed class Sprint3UiContractTests
{
private static readonly string[] MigratedScreens =
[
"FieldLogger/Views/HomePage.xaml",
"FieldLogger/Views/JobsPage.xaml",
"FieldLogger/Views/JobDetailPage.xaml",
"FieldLogger/Views/MapPage.xaml",
];
[Fact]
public void Migrated_screens_use_only_semantic_color_resources()
{
var hardCodedColor = new Regex(@"#[0-9a-fA-F]{3,8}\b", RegexOptions.CultureInvariant);
foreach (var path in MigratedScreens)
{
var text = File.ReadAllText(FromRoot(path));
Assert.False(hardCodedColor.IsMatch(text), $"Page-level color literal found in {path}");
Assert.DoesNotContain("TextColor=\"Gray\"", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DodgerBlue", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("IndianRed", text, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void Every_referenced_Survey_Teal_token_exists_in_generated_resources()
{
var resourceText = string.Concat(
File.ReadAllText(FromRoot("FieldLogger/Resources/Styles/GeneratedTokens.xaml")),
File.ReadAllText(FromRoot("FieldLogger/Resources/Styles/Styles.xaml")));
var defined = Regex.Matches(resourceText, "x:Key=\\\"(?<key>Ul[^\\\"]+)\\\"")
.Select(match => match.Groups["key"].Value)
.ToHashSet(StringComparer.Ordinal);
var resourceReference = new Regex(@"\{(?:Static|Dynamic)Resource (?<key>Ul[^}\s]+)\}");
foreach (var path in MigratedScreens.Append("FieldLogger/AppShell.xaml"))
{
foreach (Match match in resourceReference.Matches(File.ReadAllText(FromRoot(path))))
{
var key = match.Groups["key"].Value;
Assert.Contains(key, defined);
}
}
}
[Fact]
public void App_loads_generated_tokens_before_shared_component_styles()
{
var app = File.ReadAllText(FromRoot("FieldLogger/App.xaml"));
var generatedIndex = app.IndexOf("GeneratedTokens.xaml", StringComparison.Ordinal);
var stylesIndex = app.IndexOf("Styles.xaml", StringComparison.Ordinal);
Assert.True(generatedIndex >= 0, "App.xaml must merge GeneratedTokens.xaml.");
Assert.True(stylesIndex > generatedIndex, "Generated tokens must load before component styles.");
}
[Fact]
public void Migrated_screens_consume_shared_component_styles()
{
var migrated = string.Concat(MigratedScreens.Select(path => File.ReadAllText(FromRoot(path))));
Assert.Contains("UlCard", migrated, StringComparison.Ordinal);
Assert.Contains("UlJobRow", migrated, StringComparison.Ordinal);
Assert.Contains("UlStatusBadge", migrated, StringComparison.Ordinal);
Assert.Contains("UlButtonSecondary", migrated, StringComparison.Ordinal);
}
[Fact]
public void Every_page_button_declares_at_least_the_minimum_touch_target()
{
var accepted = new[]
{
"{StaticResource UlTouchMinimum}",
"{StaticResource UlTouchComfortable}",
"{StaticResource UlTouchPrimary}",
};
foreach (var path in MigratedScreens)
{
var document = XDocument.Load(FromRoot(path));
foreach (var button in document.Descendants().Where(e => e.Name.LocalName == "Button"))
{
var target = button.Attribute("MinimumHeightRequest")?.Value;
Assert.Contains(target, accepted);
}
}
}
[Fact]
public void Production_shell_does_not_expose_Debug_as_a_primary_tab()
{
var shell = File.ReadAllText(FromRoot("FieldLogger/AppShell.xaml"));
Assert.DoesNotContain("Title=\"Debug\"", shell, StringComparison.Ordinal);
Assert.DoesNotContain("views:DebugPage", shell, StringComparison.Ordinal);
Assert.DoesNotContain("<TabBar", shell, StringComparison.Ordinal);
Assert.DoesNotContain("Title=\"Home\"", shell, StringComparison.Ordinal);
Assert.True(shell.IndexOf("Title=\"Jobs\"", StringComparison.Ordinal) <
shell.IndexOf("Title=\"Map\"", StringComparison.Ordinal),
"Jobs must be the first primary route so it is selected at startup.");
}
[Fact]
public void Home_explains_real_receiver_capture_without_inventing_an_app_command()
{
var home = File.ReadAllText(FromRoot("FieldLogger/Views/HomePage.xaml"));
var state = File.ReadAllText(FromRoot("FieldLogger/ViewModels/HomeViewModel.cs"));
Assert.Contains("CAPTURE FROM RECEIVER", home, StringComparison.Ordinal);
Assert.Contains("Press LOG on the locating receiver", state, StringComparison.Ordinal);
Assert.DoesNotContain("CaptureCommand", home, StringComparison.Ordinal);
}
[Fact]
public void Primary_pages_use_status_chrome_and_floating_menu()
{
foreach (var path in new[]
{
"FieldLogger/Views/JobsPage.xaml",
"FieldLogger/Views/MapPage.xaml",
"FieldLogger/Views/SettingsPage.xaml",
})
{
var page = File.ReadAllText(FromRoot(path));
Assert.Contains("controls:AppStatusBar", page, StringComparison.Ordinal);
Assert.Contains("controls:FloatingMenuButton", page, StringComparison.Ordinal);
Assert.Contains("Shell.NavBarIsVisible=\"False\"", page, StringComparison.Ordinal);
}
}
[Fact]
public void Settings_hides_manual_map_and_broker_configuration()
{
var settings = File.ReadAllText(FromRoot("FieldLogger/Views/SettingsPage.xaml"));
Assert.DoesNotContain("Google Maps API Key", settings, StringComparison.Ordinal);
Assert.DoesNotContain("Mqtt", settings, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Durable cloud sync", settings, StringComparison.Ordinal);
Assert.Contains("OpenDebugCommand", settings, StringComparison.Ordinal);
}
[Fact]
public void Map_has_no_heading_panel_and_fills_its_root_grid()
{
var map = File.ReadAllText(FromRoot("FieldLogger/Views/MapPage.xaml"));
Assert.DoesNotContain("LIVE MAP", map, StringComparison.Ordinal);
Assert.Contains("<ContentView x:Name=\"MapHost\"", map, StringComparison.Ordinal);
}
[Fact]
public void User_facing_app_name_is_UM_Trace()
{
var shell = File.ReadAllText(FromRoot("FieldLogger/AppShell.xaml"));
var settings = File.ReadAllText(FromRoot("FieldLogger/Views/SettingsPage.xaml"));
Assert.Contains("Title=\"UM Trace\"", shell, StringComparison.Ordinal);
Assert.Contains("UM Trace v", settings, StringComparison.Ordinal);
}
[Fact]
public void Jobs_uses_a_stable_full_height_refresh_layout()
{
var page = File.ReadAllText(FromRoot("FieldLogger/Views/JobsPage.xaml"));
var viewModel = File.ReadAllText(FromRoot("FieldLogger/ViewModels/JobsViewModel.cs"));
Assert.Contains("IsRefreshing=\"{Binding IsRefreshing, Mode=OneWay}\"", page, StringComparison.Ordinal);
Assert.DoesNotContain("IsRefreshing=\"{Binding IsBusy}\"", page, StringComparison.Ordinal);
Assert.Contains("ItemsUpdatingScrollMode=\"KeepItemsInView\"", page, StringComparison.Ordinal);
Assert.Contains("HorizontalOptions=\"Fill\" VerticalOptions=\"Fill\"", page, StringComparison.Ordinal);
Assert.DoesNotContain("Padding=\"16,16,16,88\"", page, StringComparison.Ordinal);
Assert.Contains("ApplySnapshot(items)", viewModel, StringComparison.Ordinal);
Assert.DoesNotContain("Jobs.Clear()", viewModel, StringComparison.Ordinal);
}
[Fact]
public void Device_deploy_rebuilds_Xaml_and_brand_assets_from_a_clean_iOS_output()
{
var script = File.ReadAllText(FromRoot("scripts/deploy-ios-device.sh"));
var cleanIndex = script.IndexOf("\"${dotnet_bin}\" clean", StringComparison.Ordinal);
var runIndex = script.IndexOf("\"${dotnet_bin}\" build", StringComparison.Ordinal);
Assert.True(cleanIndex >= 0, "Device deployment must clean stale partial MAUI outputs.");
Assert.True(runIndex > cleanIndex, "The clean must run before the device build.");
Assert.Contains("-p:RuntimeIdentifier=ios-arm64", script, StringComparison.Ordinal);
}
private static string FromRoot(string relativePath) => Path.Combine(FindRepositoryRoot(), relativePath);
private static string FindRepositoryRoot()
{
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
directory is not null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "FieldLogger.sln")))
return directory.FullName;
}
throw new DirectoryNotFoundException("Could not find the FieldLogger repository root.");
}
}