Compare commits

...

10 Commits

Author SHA1 Message Date
Joseph Doherty a6c4cc2173 Update windchill connection string to QA failover host
Switch from wndchl-db-vt01 to WNCHLSQLQA with MultiSubnetFailover for
availability group connectivity.
2026-05-04 07:49:51 -04:00
Joseph Doherty ddc782dc76 Add DevLoader project reference 2026-02-13 10:11:01 -05:00
Joseph Doherty 1b9367dcbb Redesign refresh status table with summary counts and detail popup, sort pipeline dropdown alphabetically
Replace 11 per-table record columns on /refresh-status with Passed/Failed summary counts and a click-to-expand detail dialog showing per-table results. Add date-range SQL query to push filtering to the database. Sort pipeline dropdown alphabetically on /data-sync/requests.
2026-02-11 19:00:53 -05:00
Joseph Doherty 12cf94a9dc Fix Blazor WASM loading, add missing filter panel bind parameters, and gate nav behind auth
Remove UseBlazorFrameworkFiles() that blocked MapStaticAssets() from serving fingerprinted
Blazor framework files. Add Operators/ProfitCenters/WorkCenters alias parameters to their
respective filter panels so @bind-* two-way binding works in SearchEdit. Move all nav links
inside AuthorizeView so they only show when logged in.
2026-02-10 08:56:42 -05:00
Joseph Doherty 16b21ac243 Add Encrypt=false to Docker connection strings, gate nav links behind auth, and fix navbar layout
Add Encrypt=false to runtime Docker connection strings for SQL Server
compatibility. Wrap admin nav links (Search Queue, Refresh Status, Data
Sync) in AuthorizeView so they only render for authenticated users. Fix
navbar-nav horizontal layout with explicit flex-direction and an override
rule for hosted Blazor assets.
2026-02-10 08:06:16 -05:00
Joseph Doherty 9bd5e340b0 Convert XML list markup to plain numbered text in UI test remarks
Replace <list type="number"><item>...</item></list> with plain numbered
lines in method-level <remarks> blocks across 23 UI test files to match
the codebase convention of using simple text in XML doc comments.
2026-02-10 08:05:42 -05:00
dohejw01 cd219ae00b Merge pull request #1 from dohejw01/codex/playwright-dotnet-migration
Codex/playwright dotnet migration
2026-02-10 07:49:09 -05:00
Joseph Doherty 78e67c2aab Migrate UI tests to Playwright dotta 2026-02-10 07:47:48 -05:00
Joseph Doherty c3a9a6b19c Migrate NEW solution to central package management 2026-02-06 18:47:52 -05:00
Joseph Doherty 562f7e9e37 Migrate Playwright suite to .NET UI tests and deprecate TS project 2026-02-06 18:44:40 -05:00
158 changed files with 2447 additions and 409 deletions
+78
View File
@@ -0,0 +1,78 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageFloatingVersionsEnabled>true</CentralPackageFloatingVersionsEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="11.2.*" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.2.*" />
<PackageVersion Include="Avalonia.Desktop" Version="11.2.*" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.2.*" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.2.*" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="11.2.*" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.2.*" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Cronos" Version="0.11.1" />
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="dbup-sqlserver" Version="6.0.16" />
<PackageVersion Include="DiffPlex" Version="1.7.*" />
<PackageVersion Include="LdapForNet" Version="2.7.15" />
<PackageVersion Include="MessageBox.Avalonia" Version="3.1.*" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="NPOI" Version="2.7.5" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="OneOf" Version="3.0.271" />
<PackageVersion Include="OneOf.SourceGenerator" Version="3.0.271" />
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.26.0" />
<PackageVersion Include="protobuf-net-data" Version="4.1.0" />
<PackageVersion Include="Radzen.Blazor" Version="8.4.2" />
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageVersion Include="SecureStore" Version="1.2.0" />
<PackageVersion Include="Serilog" Version="4.*" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="8.0.*" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.*" />
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.*" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="SqlKata" Version="3.2.3" />
<PackageVersion Include="SqlKata.Execution" Version="3.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.DirectoryServices.Protocols" Version="10.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="ZstdSharp.Port" Version="0.8.1" />
<PackageVersion Update="Dapper" Version="2.1.35" Condition="'$(MSBuildProjectName)' == 'JdeScoping.Database.Tests'" />
<PackageVersion Update="Microsoft.Data.SqlClient" Version="5.2.2" Condition="'$(MSBuildProjectName)' == 'JdeScoping.Database.Tests'" />
<PackageVersion Update="Microsoft.NET.Test.Sdk" Version="17.12.0" Condition="'$(MSBuildProjectName)' == 'JdeScoping.Api.IntegrationTests' or '$(MSBuildProjectName)' == 'JdeScoping.Api.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Cli.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Core.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Ui.Tests'" />
<PackageVersion Update="Shouldly" Version="4.2.1" Condition="'$(MSBuildProjectName)' == 'JdeScoping.Api.IntegrationTests' or '$(MSBuildProjectName)' == 'JdeScoping.Api.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Cli.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Core.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Ui.Tests'" />
<PackageVersion Update="xunit.runner.visualstudio" Version="3.0.1" Condition="'$(MSBuildProjectName)' == 'JdeScoping.Api.IntegrationTests' or '$(MSBuildProjectName)' == 'JdeScoping.Api.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Cli.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Core.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ConfigManager.Ui.Tests'" />
<PackageVersion Update="xunit.runner.visualstudio" Version="3.0.2" Condition="'$(MSBuildProjectName)' == 'JdeScoping.DataAccess.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.DataSync.Dev.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.DataSync.Tests' or '$(MSBuildProjectName)' == 'JdeScoping.ExcelIO.Tests'" />
</ItemGroup>
</Project>
+2
View File
@@ -15,6 +15,7 @@
<Project Path="src/Utils/JdeScoping.ConfigManager.Core/JdeScoping.ConfigManager.Core.csproj" />
<Project Path="src/Utils/JdeScoping.ConfigManager.Cli/JdeScoping.ConfigManager.Cli.csproj" />
<Project Path="src/Utils/JdeScoping.ConfigManager.Ui/JdeScoping.ConfigManager.Ui.csproj" />
<Project Path="src/Utils/JdeScoping.DevLoader/JdeScoping.DevLoader.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/JdeScoping.Api.Tests/JdeScoping.Api.Tests.csproj" />
@@ -28,6 +29,7 @@
<Project Path="tests/JdeScoping.ExcelIO.Tests/JdeScoping.ExcelIO.Tests.csproj" />
<Project Path="tests/JdeScoping.Host.Tests/JdeScoping.Host.Tests.csproj" />
<Project Path="tests/JdeScoping.Infrastructure.Tests/JdeScoping.Infrastructure.Tests.csproj" />
<Project Path="tests/JdeScoping.Ui.Tests/JdeScoping.Ui.Tests.csproj" />
</Folder>
<Folder Name="/tests/utils/">
<Project Path="tests/Utils/JdeScoping.ConfigManager.Core.Tests/JdeScoping.ConfigManager.Core.Tests.csproj" />
@@ -1,8 +1,8 @@
{
"ConnectionStrings": {
"LotFinder": "Server=host.docker.internal,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;TrustServerCertificate=true",
"LotFinderDB": "Server=host.docker.internal,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;TrustServerCertificate=true",
"SqlServer": "Server=host.docker.internal,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;TrustServerCertificate=true",
"LotFinder": "Server=host.docker.internal,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;Encrypt=false;TrustServerCertificate=true",
"LotFinderDB": "Server=host.docker.internal,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;Encrypt=false;TrustServerCertificate=true",
"SqlServer": "Server=host.docker.internal,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;Encrypt=false;TrustServerCertificate=true",
"GIW": "Data Source=localhost:1521/GIW;User Id=placeholder;Password=placeholder"
},
"DataAccess": {
@@ -73,6 +73,7 @@ public class ManualSyncController : ApiControllerBase
public ActionResult<List<PipelineInfoViewModel>> GetPipelines()
{
var pipelines = _pipelineRegistry.GetEnabledPipelines()
.OrderBy(p => p.Name)
.Select(p => new PipelineInfoViewModel
{
Name = p.Name,
@@ -39,33 +39,33 @@ public class RefreshStatusController : ApiControllerBase
[FromQuery] DateTime maxDT,
CancellationToken ct)
{
// Get raw data updates from repository
var updates = await _repository.GetLastDataUpdatesAsync(ct);
// Get data updates filtered in SQL by date range (end of day for maxDT)
var updates = await _repository.GetDataUpdatesInRangeAsync(minDT, maxDT.Date.AddDays(1), ct);
// Filter by date range
var filtered = updates
.Where(u => u.StartDt >= minDT && u.StartDt <= maxDT.AddDays(1))
.ToList();
// Group by StartDt (rounded to minute) to aggregate multiple table updates into single rows
var aggregated = filtered
// Group by StartDt (rounded to minute) to aggregate multiple table updates into single rows.
// Within each run, deduplicate by TableName so each table appears once even when
// multiple UpdateTypes (Hourly/Daily/Mass) ran in the same minute.
var aggregated = updates
.GroupBy(u => new DateTime(u.StartDt.Year, u.StartDt.Month, u.StartDt.Day, u.StartDt.Hour, u.StartDt.Minute, 0))
.Select(g => new DataUpdateDto
.Select(g =>
{
var items = g.GroupBy(u => u.TableName)
.Select(tg => new DataUpdateItemDto
{
TableName = tg.Key,
WasSuccessful = tg.All(u => u.WasSuccessful),
NumberRecords = tg.Sum(u => Math.Max(0, u.NumberRecords))
}).OrderBy(i => i.TableName).ToList();
return new DataUpdateDto
{
StartDt = g.Key,
EndDt = g.Max(u => u.EndDt),
WasSuccessful = g.All(u => u.WasSuccessful),
BranchRecords = (int)(g.FirstOrDefault(u => u.TableName == "Branch")?.NumberRecords ?? 0),
ProfitCenterRecords = (int)(g.FirstOrDefault(u => u.TableName == "ProfitCenter")?.NumberRecords ?? 0),
WorkCenterRecords = (int)(g.FirstOrDefault(u => u.TableName == "WorkCenter")?.NumberRecords ?? 0),
OrgHierarchyRecords = (int)(g.FirstOrDefault(u => u.TableName == "OrgHierarchy")?.NumberRecords ?? 0),
StatusCodeRecords = (int)(g.FirstOrDefault(u => u.TableName == "StatusCode")?.NumberRecords ?? 0),
UserRecords = (int)(g.FirstOrDefault(u => u.TableName == "JdeUser")?.NumberRecords ?? 0),
ItemRecords = (int)(g.FirstOrDefault(u => u.TableName == "Item")?.NumberRecords ?? 0),
LotRecords = (int)(g.FirstOrDefault(u => u.TableName == "Lot")?.NumberRecords ?? 0),
WorkOrderRecords = (int)(g.FirstOrDefault(u => u.TableName.Contains("WorkOrder_"))?.NumberRecords ?? 0),
WorkOrderStepRecords = (int)(g.FirstOrDefault(u => u.TableName.Contains("WorkOrderStep"))?.NumberRecords ?? 0),
WorkOrderComponentRecords = (int)(g.FirstOrDefault(u => u.TableName.Contains("WorkOrderComponent"))?.NumberRecords ?? 0)
WasSuccessful = items.All(i => i.WasSuccessful),
PassedCount = items.Count(i => i.WasSuccessful),
FailedCount = items.Count(i => !i.WasSuccessful),
Items = items
};
})
.OrderByDescending(d => d.StartDt)
.ToList();
+2 -2
View File
@@ -18,8 +18,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="System.DirectoryServices.Protocols" />
</ItemGroup>
</Project>
@@ -34,20 +34,15 @@ public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where T
[Parameter]
public bool IsReadOnly { get; set; }
/// <summary>
/// The current search text.
/// </summary>
protected string SearchText { get; set; } = "";
/// <summary>
/// The search results from the API.
/// </summary>
protected List<TItem> SearchResults { get; set; } = [];
/// <summary>
/// The currently selected item from search results.
/// The currently selected value from the dropdown.
/// </summary>
protected TItem? SelectedItem { get; set; }
protected TItem? SelectedValue { get; set; }
/// <summary>
/// Reference to the data grid for explicit refresh.
@@ -93,13 +88,6 @@ public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where T
/// <returns>The unique key value.</returns>
protected abstract object GetItemKey(TItem item);
/// <summary>
/// Gets the display text value for an item (used for matching in autocomplete).
/// </summary>
/// <param name="item">The item to get the display text for.</param>
/// <returns>The display text value.</returns>
protected abstract string GetDisplayText(TItem item);
/// <summary>
/// Handles the autocomplete search.
/// </summary>
@@ -107,7 +95,7 @@ public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where T
/// <returns>A task representing the asynchronous search operation.</returns>
protected async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 2)
{
SearchResults = await SearchApiAsync(args.Filter);
}
@@ -117,40 +105,23 @@ public abstract class AutocompleteFilterPanelBase<TItem> : ComponentBase where T
}
}
/// <summary>
/// Handles selection from the autocomplete.
/// </summary>
/// <param name="value">The selected value from the autocomplete control.</param>
protected void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
SelectedItem = SearchResults.FirstOrDefault(i => GetDisplayText(i) == text);
}
else
{
SelectedItem = null;
}
}
/// <summary>
/// Adds the selected item to the list.
/// </summary>
protected async Task AddItemAsync()
{
if (SelectedItem != null)
if (SelectedValue != null)
{
var selectedKey = GetItemKey(SelectedItem);
var selectedKey = GetItemKey(SelectedValue);
var isDuplicate = Items.Any(i => GetItemKey(i).Equals(selectedKey));
if (!isDuplicate)
{
Items.Add(SelectedItem);
Items.Add(SelectedValue);
await ItemsChanged.InvokeAsync(Items);
}
}
SearchText = "";
SelectedItem = null;
SelectedValue = null;
}
/// <summary>
@@ -24,19 +24,17 @@
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Item Number" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="ItemNumber"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search items (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<div class="filter-input-row rz-mb-3">
<div class="filter-input-col">
<label class="field-label">Item Number</label>
<RadzenDropDown @bind-Value="_selectedValue" Data="@_searchResults" TextProperty="ItemNumber"
LoadData="@OnSearchAsync" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive"
AllowFiltering="true" AllowClear="true"
Placeholder="Search items (2+ chars)..." Style="width: 100%;" />
</div>
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
Disabled="@(_selectedValue == null)" />
</div>
}
<RadzenDataGrid @ref="_grid" Data="@Items" TItem="ItemViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
@@ -72,9 +70,8 @@
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private string _searchText = "";
private List<ItemViewModel> _searchResults = [];
private ItemViewModel? _selectedItem;
private ItemViewModel? _selectedValue;
private bool _isUploading;
private RadzenDataGrid<ItemViewModel>? _grid;
@@ -98,27 +95,14 @@
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.ItemNumber == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !Items.Any(i => i.ItemNumber == _selectedItem.ItemNumber))
if (_selectedValue != null && !Items.Any(i => i.ItemNumber == _selectedValue.ItemNumber))
{
Items.Add(_selectedItem);
Items.Add(_selectedValue);
await ItemsChanged.InvokeAsync(Items);
}
_searchText = "";
_selectedItem = null;
_selectedValue = null;
}
private async Task DeleteItem(ItemViewModel item)
@@ -15,19 +15,17 @@
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<div class="filter-input-row rz-mb-3">
<div class="filter-input-col">
<label class="field-label">@SearchFieldLabel</label>
<RadzenDropDown @bind-Value="SelectedValue" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive"
AllowFiltering="true" AllowClear="true"
Placeholder="@SearchPlaceholder" Style="width: 100%;" />
</div>
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
Disabled="@(SelectedValue == null)" />
</div>
}
<RadzenDataGrid @ref="Grid" Data="@Items" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
@@ -48,14 +46,27 @@
</RadzenCard>
@code {
[Parameter]
public List<OperatorViewModel> Operators
{
get => Items;
set => Items = value;
}
[Parameter]
public EventCallback<List<OperatorViewModel>> OperatorsChanged
{
get => ItemsChanged;
set => ItemsChanged = value;
}
protected override string PanelTitle => "Filter by Operator";
protected override string SearchPlaceholder => "Search operators (3+ chars)...";
protected override string SearchPlaceholder => "Search operators (2+ chars)...";
protected override string SearchFieldLabel => "Name";
protected override string AutocompleteTextProperty => "FullName";
protected override string ClearConfirmMessage => "Are you sure you want to clear all operators?";
protected override object GetItemKey(OperatorViewModel item) => item.UserId;
protected override string GetDisplayText(OperatorViewModel item) => item.FullName;
protected override async Task<List<OperatorViewModel>> SearchApiAsync(string filter)
{
@@ -14,19 +14,17 @@
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<div class="filter-input-row rz-mb-3">
<div class="filter-input-col">
<label class="field-label">@SearchFieldLabel</label>
<RadzenDropDown @bind-Value="SelectedValue" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive"
AllowFiltering="true" AllowClear="true"
Placeholder="@SearchPlaceholder" Style="width: 100%;" />
</div>
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
Disabled="@(SelectedValue == null)" />
</div>
}
<RadzenDataGrid @ref="Grid" Data="@Items" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
@@ -46,14 +44,27 @@
</RadzenCard>
@code {
[Parameter]
public List<ProfitCenterViewModel> ProfitCenters
{
get => Items;
set => Items = value;
}
[Parameter]
public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged
{
get => ItemsChanged;
set => ItemsChanged = value;
}
protected override string PanelTitle => "Filter by Profit Center";
protected override string SearchPlaceholder => "Search profit centers (3+ chars)...";
protected override string SearchPlaceholder => "Search profit centers (2+ chars)...";
protected override string SearchFieldLabel => "Profit Center";
protected override string AutocompleteTextProperty => "Code";
protected override string ClearConfirmMessage => "Are you sure you want to clear all profit centers?";
protected override object GetItemKey(ProfitCenterViewModel item) => item.Code;
protected override string GetDisplayText(ProfitCenterViewModel item) => item.Code;
protected override async Task<List<ProfitCenterViewModel>> SearchApiAsync(string filter)
{
@@ -14,19 +14,17 @@
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="@SearchFieldLabel" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="SearchText" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="@SearchPlaceholder"
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<div class="filter-input-row rz-mb-3">
<div class="filter-input-col">
<label class="field-label">@SearchFieldLabel</label>
<RadzenDropDown @bind-Value="SelectedValue" Data="@SearchResults" TextProperty="@AutocompleteTextProperty"
LoadData="@OnSearchAsync" FilterCaseSensitivity="FilterCaseSensitivity.CaseInsensitive"
AllowFiltering="true" AllowClear="true"
Placeholder="@SearchPlaceholder" Style="width: 100%;" />
</div>
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(SelectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
Disabled="@(SelectedValue == null)" />
</div>
}
<RadzenDataGrid @ref="Grid" Data="@Items" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
@@ -46,14 +44,27 @@
</RadzenCard>
@code {
[Parameter]
public List<WorkCenterViewModel> WorkCenters
{
get => Items;
set => Items = value;
}
[Parameter]
public EventCallback<List<WorkCenterViewModel>> WorkCentersChanged
{
get => ItemsChanged;
set => ItemsChanged = value;
}
protected override string PanelTitle => "Filter by Work Center";
protected override string SearchPlaceholder => "Search work centers (3+ chars)...";
protected override string SearchPlaceholder => "Search work centers (2+ chars)...";
protected override string SearchFieldLabel => "Work Center";
protected override string AutocompleteTextProperty => "Code";
protected override string ClearConfirmMessage => "Are you sure you want to clear all work centers?";
protected override object GetItemKey(WorkCenterViewModel item) => item.Code;
protected override string GetDisplayText(WorkCenterViewModel item) => item.Code;
protected override async Task<List<WorkCenterViewModel>> SearchApiAsync(string filter)
{
@@ -0,0 +1,38 @@
@*
RefreshStatusDetailDialog.razor - Per-table detail popup for a sync run.
Displays a grid of individual table sync results (table name, record count, pass/fail).
*@
<RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">
@SyncRun.StartDt.ToString("MM/dd/yyyy hh:mm tt") — @(SyncRun.EndDt?.ToString("hh:mm tt") ?? "In Progress")
</RadzenText>
<RadzenDataGrid Data="@SyncRun.Items" TItem="DataUpdateItemDto" AllowSorting="true" Style="text-align: center;">
<Columns>
<RadzenDataGridColumn TItem="DataUpdateItemDto" Property="TableName" Title="Table" Width="200px" TextAlign="TextAlign.Left" SortOrder="SortOrder.Ascending" />
<RadzenDataGridColumn TItem="DataUpdateItemDto" Property="NumberRecords" Title="Records" Width="120px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateItemDto" Property="WasSuccessful" Title="Status" Width="100px" TextAlign="TextAlign.Center">
<Template Context="item">
@if (item.WasSuccessful)
{
<RadzenBadge BadgeStyle="BadgeStyle.Success" Text="PASS" />
}
else
{
<RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="FAIL" />
}
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
<div class="rz-mt-4" style="text-align: right;">
<RadzenButton Text="Close" ButtonStyle="ButtonStyle.Light" Click="@(() => DialogService.Close())" />
</div>
@code {
[Parameter] public DataUpdateDto SyncRun { get; set; } = default!;
[Inject] private DialogService DialogService { get; set; } = default!;
}
@@ -7,61 +7,55 @@
@namespace JdeScoping.Client.Components.Search
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
<div class="search-details-header">
<strong>Search Details</strong>
</div>
<RadzenRow Gap="1rem">
<RadzenColumn Size="12">
<RadzenFormField Text="Search Type" Style="width: 100%;">
<label class="field-label">Search Type</label>
<RadzenDropDown @bind-Value="SelectedSearchType" Data="@ValidCombinations" TextProperty="Name" ValueProperty="Id"
Placeholder="Select type" Disabled="@Search.IsReadOnly" Change="@OnSearchTypeChangedHandler" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="12">
<RadzenFormField Text="Name" Style="width: 100%;">
<label class="field-label">Name</label>
<RadzenTextBox @bind-Value="Search.Name" Disabled="@Search.IsReadOnly" Style="width: 100%;" />
</RadzenFormField>
<ValidationMessage For="@(() => Search.Name)" class="validation-message text-danger" />
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="Submitted At" Style="width: 100%;">
<RadzenTextBox Value="@(Search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
<label class="field-label">Submitted At</label>
<RadzenTextBox Value="@(Search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" class="readonly-input" Style="width: 100%;" />
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Started At" Style="width: 100%;">
<RadzenTextBox Value="@(Search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
<label class="field-label">Started At</label>
<RadzenTextBox Value="@(Search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" class="readonly-input" Style="width: 100%;" />
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Completed At" Style="width: 100%;">
<RadzenTextBox Value="@(Search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
<label class="field-label">Completed At</label>
<RadzenTextBox Value="@(Search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" class="readonly-input" Style="width: 100%;" />
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="User" Style="width: 100%;">
<RadzenTextBox Value="@Search.UserName" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
<label class="field-label">User</label>
<RadzenTextBox Value="@Search.UserName" ReadOnly="true" class="readonly-input" Style="width: 100%;" />
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Status" Style="width: 100%;">
<label class="field-label">Status</label>
<RadzenTextBox Value="@Search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {Search.StatusColor};")" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
@if (Search.HasResults)
{
<RadzenFormField Text=" " Style="width: 100%;">
<label class="field-label">&nbsp;</label>
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@OnDownloadResults" Style="width: 100%;" />
</RadzenFormField>
}
</RadzenColumn>
</RadzenRow>
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Radzen.Blazor" Version="8.4.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Radzen.Blazor" />
</ItemGroup>
<ItemGroup>
@@ -9,11 +9,15 @@
<div class="navbar-left">
<a href="/" class="navbar-brand">JDE Scoping Tool</a>
<nav class="navbar-nav">
<AuthorizeView>
<Authorized>
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Searches</NavLink>
<NavLink class="nav-link" href="/search">New Search</NavLink>
<NavLink class="nav-link" href="/search/queue">Search Queue</NavLink>
<NavLink class="nav-link" href="/refresh-status">Refresh Status</NavLink>
<NavLink class="nav-link" href="/data-sync/requests">Data Sync</NavLink>
</Authorized>
</AuthorizeView>
</nav>
</div>
<div class="navbar-right">
@@ -2,11 +2,12 @@
RefreshStatus.razor - Data cache refresh status dashboard.
Shows the status of JDE/CMS data synchronization jobs (hourly, daily, mass).
Allows filtering by date range and entity name.
Allows filtering by date range. Click a row to see per-table detail.
*@
@page "/refresh-status"
@attribute [Authorize]
@inject IRefreshStatusService RefreshStatusService
@inject DialogService DialogService
<PageTitle>Cache Refresh Status - JDE Scoping Tool</PageTitle>
@@ -38,38 +39,30 @@
else
{
<RadzenDataGrid Data="@_results" TItem="DataUpdateDto" AllowSorting="true" AllowPaging="true" PageSize="20"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true" Style="text-align: center;">
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true" Style="text-align: center;"
SelectionMode="DataGridSelectionMode.Single" RowSelect="@OnRowSelect">
<Columns>
<RadzenDataGridColumn TItem="DataUpdateDto" Property="StartDT" Title="Start" Width="160px">
<RadzenDataGridColumn TItem="DataUpdateDto" Property="StartDt" Title="Start" Width="180px">
<Template Context="item">
@item.StartDt.ToString("MM/dd/yyyy hh:mm tt")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="DataUpdateDto" Property="EndDT" Title="End" Width="160px">
<RadzenDataGridColumn TItem="DataUpdateDto" Property="EndDt" Title="End" Width="180px">
<Template Context="item">
@(item.EndDt?.ToString("MM/dd/yyyy hh:mm tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="DataUpdateDto" Property="BranchRecords" Title="Branch" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="ProfitCenterRecords" Title="Profit Center" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="WorkCenterRecords" Title="Work Center" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="OrgHierarchyRecords" Title="Org Hierarchy" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="StatusCodeRecords" Title="Status Code" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="UserRecords" Title="User" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="ItemRecords" Title="Item" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="LotRecords" Title="Lot" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="WorkOrderRecords" Title="Work Order" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="WorkOrderStepRecords" Title="WO Step" Width="90px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="WorkOrderComponentRecords" Title="WO Component" Width="110px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="WasSuccessful" Title="Was Successful?" Width="120px" TextAlign="TextAlign.Center">
<RadzenDataGridColumn TItem="DataUpdateDto" Property="PassedCount" Title="Passed" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="FailedCount" Title="Failed" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateDto" Property="WasSuccessful" Title="Status" Width="120px" TextAlign="TextAlign.Center">
<Template Context="item">
@if (item.WasSuccessful)
{
<RadzenBadge BadgeStyle="BadgeStyle.Success" Text="YES" />
<RadzenBadge BadgeStyle="BadgeStyle.Success" Text="PASS" />
}
else
{
<RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="NO" />
<RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="FAIL" />
}
</Template>
</RadzenDataGridColumn>
@@ -96,7 +89,6 @@ else
try
{
_results = await RefreshStatusService.GetRefreshStatusAsync(_minDt, _maxDt);
// Sort by StartDT descending
_results = _results.OrderByDescending(r => r.StartDt).ToList();
}
finally
@@ -104,4 +96,12 @@ else
_isLoading = false;
}
}
private async Task OnRowSelect(DataUpdateDto row)
{
await DialogService.OpenAsync<JdeScoping.Client.Components.RefreshStatus.RefreshStatusDetailDialog>(
"Sync Run Detail",
new Dictionary<string, object> { { "SyncRun", row } },
new DialogOptions { Width = "600px", Resizable = true, Draggable = true });
}
}
@@ -29,7 +29,7 @@
<RadzenText TextStyle="TextStyle.H4" class="rz-m-0">Search</RadzenText>
@if (!_search.IsReadOnly)
{
<RadzenButton Text="Submit" Icon="send" ButtonStyle="ButtonStyle.Primary" Click="@SubmitSearchAsync" IsBusy="@_isSubmitting" BusyText="Submitting..." />
<RadzenButton Text="Submit" Icon="send" ButtonStyle="ButtonStyle.Primary" Click="@SubmitSearchAsync" IsBusy="@_isSubmitting" BusyText="Submitting..." class="btn-submit-blue" />
}
</RadzenStack>
@@ -232,10 +232,20 @@ code {
.navbar-nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
}
/* Ensure top-header nav stays horizontal when host serves Blazor assets. */
.navbar-fixed-top .navbar-left nav.navbar-nav {
display: inline-flex !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
align-items: center !important;
}
.nav-link {
color: #9d9d9d;
text-decoration: none;
@@ -286,6 +296,53 @@ code {
padding: 0;
}
/* Legacy-style form labels - bold, above the field */
.field-label {
font-weight: bold;
margin-bottom: 0.25rem;
font-size: 0.95rem;
color: #333;
}
/* Read-only input styling - grey background */
.readonly-input {
background-color: #eee !important;
color: #555 !important;
cursor: default;
}
/* Search details card header bar */
.search-details-header {
background-color: #f5f5f5;
padding: 0.75rem 1rem;
border-bottom: 1px solid #ddd;
margin: -1.25rem -1.25rem 1rem -1.25rem;
font-size: 1rem;
}
/* Blue submit button (legacy style) */
.btn-submit-blue {
background-color: #337ab7 !important;
border-color: #2e6da4 !important;
color: #fff !important;
}
.btn-submit-blue:hover {
background-color: #286090 !important;
border-color: #204d74 !important;
}
/* Filter panel input row - align Add button with input bottom */
.filter-input-row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
.filter-input-row .filter-input-col {
flex: 0 1 41.67%;
}
/* RadzenUpload inline button style (no drop zone) */
.rz-upload-inline .rz-fileupload-buttonbar {
padding: 0;
@@ -13,4 +13,13 @@ public partial interface ILotFinderRepository
/// <param name="ct">Cancellation token.</param>
/// <returns>Latest data updates.</returns>
Task<List<DataUpdate>> GetLastDataUpdatesAsync(CancellationToken ct = default);
/// <summary>
/// Gets all data update records within a date range.
/// </summary>
/// <param name="minDt">Start of range (inclusive).</param>
/// <param name="maxDt">End of range (inclusive, end of day).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Data updates within the range.</returns>
Task<List<DataUpdate>> GetDataUpdatesInRangeAsync(DateTime minDt, DateTime maxDt, CancellationToken ct = default);
}
@@ -7,15 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.11.1" />
<PackageReference Include="OneOf" Version="3.0.271" />
<PackageReference Include="OneOf.SourceGenerator" Version="3.0.271" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="Cronos" />
<PackageReference Include="OneOf" />
<PackageReference Include="OneOf.SourceGenerator" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
</Project>
@@ -2,38 +2,25 @@ namespace JdeScoping.Core.Models.Infrastructure;
/// <summary>
/// DTO for data refresh/sync status display.
/// Aggregates record counts from multiple table updates into a single row.
/// Summarises a single sync run with pass/fail counts and per-table detail.
/// </summary>
public class DataUpdateDto
{
/// <summary>The start time of the data update.</summary>
public DateTime StartDt { get; set; }
/// <summary>The end time of the data update.</summary>
public DateTime? EndDt { get; set; }
/// <summary>The number of branch records updated.</summary>
public int BranchRecords { get; set; }
/// <summary>The number of profit center records updated.</summary>
public int ProfitCenterRecords { get; set; }
/// <summary>The number of work center records updated.</summary>
public int WorkCenterRecords { get; set; }
/// <summary>The number of organizational hierarchy records updated.</summary>
public int OrgHierarchyRecords { get; set; }
/// <summary>The number of status code records updated.</summary>
public int StatusCodeRecords { get; set; }
/// <summary>The number of user records updated.</summary>
public int UserRecords { get; set; }
/// <summary>The number of item records updated.</summary>
public int ItemRecords { get; set; }
/// <summary>The number of lot records updated.</summary>
public int LotRecords { get; set; }
/// <summary>The number of work order records updated.</summary>
public int WorkOrderRecords { get; set; }
/// <summary>The number of work order step records updated.</summary>
public int WorkOrderStepRecords { get; set; }
/// <summary>The number of work order component records updated.</summary>
public int WorkOrderComponentRecords { get; set; }
/// <summary>Whether the data update was successful.</summary>
/// <summary>Whether the data update was successful overall.</summary>
public bool WasSuccessful { get; set; }
/// <summary>Number of table syncs that succeeded.</summary>
public int PassedCount { get; set; }
/// <summary>Number of table syncs that failed.</summary>
public int FailedCount { get; set; }
/// <summary>Per-table detail for this sync run.</summary>
public List<DataUpdateItemDto> Items { get; set; } = [];
}
@@ -0,0 +1,16 @@
namespace JdeScoping.Core.Models.Infrastructure;
/// <summary>
/// Per-table detail within a data sync run.
/// </summary>
public class DataUpdateItemDto
{
/// <summary>The cache table that was synced.</summary>
public string TableName { get; set; } = string.Empty;
/// <summary>Whether this table sync succeeded.</summary>
public bool WasSuccessful { get; set; }
/// <summary>Number of records synced for this table.</summary>
public long NumberRecords { get; set; }
}
@@ -7,17 +7,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
<PackageReference Include="SqlKata" Version="3.2.3" />
<PackageReference Include="SqlKata.Execution" Version="3.2.3" />
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="SqlKata" />
<PackageReference Include="SqlKata.Execution" />
</ItemGroup>
<ItemGroup>
@@ -24,4 +24,21 @@ public static partial class LotFinderQueries
cte.NumberRecords
FROM DU_CTE cte
WHERE cte.RN = 1";
/// <summary>
/// Gets all data update records within a date range, ordered by StartDT descending.
/// </summary>
public const string SqlGetDataUpdatesInRange = @"
SELECT du.SourceSystem,
du.SourceData,
du.TableName,
du.StartDT,
du.EndDT,
du.UpdateType,
du.WasSuccessful,
du.NumberRecords
FROM dbo.DataUpdate AS du
WHERE du.StartDT >= @minDt
AND du.StartDT <= @maxDt
ORDER BY du.StartDT DESC";
}
@@ -20,4 +20,17 @@ public partial class LotFinderRepository
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
ct);
}
/// <inheritdoc/>
public async Task<List<DataUpdate>> GetDataUpdatesInRangeAsync(DateTime minDt, DateTime maxDt, CancellationToken ct = default)
{
return await ExecuteQueryAsync(
nameof(GetDataUpdatesInRangeAsync),
"SQL_GET_DATA_UPDATES_IN_RANGE",
async connection => (await connection.QueryAsync<DataUpdate>(
LotFinderQueries.SqlGetDataUpdatesInRange,
new { minDt, maxDt },
commandTimeout: _options.Value.DefaultTimeoutSeconds)).ToList(),
ct);
}
}
@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="protobuf-net-data" Version="4.1.0" />
<PackageReference Include="protobuf-net-data" />
</ItemGroup>
<ItemGroup>
@@ -17,14 +17,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.1" />
<PackageReference Include="ZstdSharp.Port" Version="0.8.1" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="ZstdSharp.Port" />
</ItemGroup>
</Project>
@@ -11,8 +11,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="dbup-sqlserver" Version="6.0.16" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="dbup-sqlserver" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
</Project>
@@ -7,12 +7,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NPOI" Version="2.7.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="NPOI" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
<ItemGroup>
@@ -12,12 +12,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
<PackageReference Include="Serilog" Version="4.*" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.*" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.*" />
<PackageReference Include="Serilog.Sinks.File" Version="6.*" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
<PropertyGroup>
-1
View File
@@ -90,7 +90,6 @@ try
}
app.UseStaticFiles();
app.UseBlazorFrameworkFiles();
app.UseRouting();
// Configure Web API middleware (authentication, authorization, controllers, SignalR hub)
@@ -7,14 +7,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="SecureStore" Version="1.2.0" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="SecureStore" />
<PackageReference Include="System.DirectoryServices.Protocols" />
</ItemGroup>
<ItemGroup>
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.*" />
<PackageReference Include="Serilog" Version="4.*" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.*" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.*" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
@@ -6,12 +6,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DiffPlex" Version="1.7.*" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.*" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.*" />
<PackageReference Include="SecureStore" Version="1.2.0" />
<PackageReference Include="DiffPlex" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="SecureStore" />
</ItemGroup>
<ItemGroup>
@@ -12,18 +12,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.*" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.*" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.*" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.*" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.*" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.*" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.*" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.*" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.*" />
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="MessageBox.Avalonia" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>jdescoping-devloader</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\JdeScoping.DataSync.Dev\JdeScoping.DataSync.Dev.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,88 @@
using System.Diagnostics;
using JdeScoping.DataSync.Dev;
using JdeScoping.DataSync.Dev.Options;
using JdeScoping.DataSync.Dev.Services;
using JdeScoping.DevLoader;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Serilog;
const string DefaultConnectionString =
"Server=localhost,1434;Database=ScopingTool;User Id=scopingapp;Password=Sc0ping@pp_Dev#2024;TrustServerCertificate=true;Encrypt=false";
// --- Parse arguments ---
string? cacheDir = null;
string connectionString = DefaultConnectionString;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--cache-dir" when i + 1 < args.Length:
cacheDir = args[++i];
break;
case "--connection-string" when i + 1 < args.Length:
connectionString = args[++i];
break;
}
}
if (string.IsNullOrWhiteSpace(cacheDir))
{
Console.Error.WriteLine("Usage: jdescoping-devloader --cache-dir <path> [--connection-string <cs>]");
return 1;
}
// --- Set up Serilog ---
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
using var loggerFactory = LoggerFactory.Create(builder =>
builder.AddSerilog(Log.Logger, dispose: false));
var logger = loggerFactory.CreateLogger<DevEtlRegistry>();
var pipelineLogger = loggerFactory.CreateLogger<JdeScoping.DataSync.Etl.Pipeline.EtlPipeline>();
// --- Build components ---
var connectionFactory = new SimpleDbConnectionFactory(connectionString);
var options = Options.Create(new DevPipelineOptions());
var pipelineFactory = new DevEtlPipelineFactory(connectionFactory, options, pipelineLogger);
var registry = new DevEtlRegistry(pipelineFactory, cacheDir, logger);
// --- Run ---
var tables = registry.GetAvailableTables().ToList();
Log.Information("Found {Count} tables to load from {Dir}", tables.Count, cacheDir);
Log.Information("Tables: {Tables}", string.Join(", ", tables));
var sw = Stopwatch.StartNew();
var results = await registry.RunAllParallelAsync(maxDegreeOfParallelism: 4);
sw.Stop();
// --- Report ---
Log.Information("=== Dev ETL Complete ({Elapsed:g}) ===", sw.Elapsed);
var succeeded = 0;
var failed = 0;
long totalRows = 0;
foreach (var r in results.OrderBy(r => r.Success ? 0 : 1))
{
var tableName = r.Steps.FirstOrDefault()?.StepName?.Replace("_Dev", "") ?? "Unknown";
if (r.Success)
{
succeeded++;
totalRows += r.TotalRows;
Log.Information(" OK {Table,-30} {Rows,10:N0} rows ({Elapsed:g})", tableName, r.TotalRows, r.Elapsed);
}
else
{
failed++;
Log.Error(" FAIL {Table,-30} {Error}", tableName, r.Error?.Message);
}
}
Log.Information("Summary: {Succeeded} succeeded, {Failed} failed, {TotalRows:N0} total rows", succeeded, failed, totalRows);
return failed > 0 ? 1 : 0;
@@ -0,0 +1,39 @@
using JdeScoping.DataAccess.Interfaces;
using Microsoft.Data.SqlClient;
using Oracle.ManagedDataAccess.Client;
namespace JdeScoping.DevLoader;
/// <summary>
/// Minimal connection factory for the dev loader.
/// Only supports SQL Server (LotFinder cache); Oracle methods throw NotSupportedException.
/// </summary>
public class SimpleDbConnectionFactory : IDbConnectionFactory
{
private readonly string _sqlConnectionString;
public SimpleDbConnectionFactory(string sqlConnectionString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sqlConnectionString);
_sqlConnectionString = sqlConnectionString;
}
public async Task<SqlConnection> CreateLotFinderConnectionAsync(CancellationToken ct = default)
{
var conn = new SqlConnection(_sqlConnectionString);
await conn.OpenAsync(ct);
return conn;
}
public Task<OracleConnection> CreateJdeConnectionAsync(CancellationToken ct = default)
=> throw new NotSupportedException("Oracle JDE connections are not available in the dev loader.");
public Task<OracleConnection> CreateJdeStageConnectionAsync(CancellationToken ct = default)
=> throw new NotSupportedException("Oracle JDE Stage connections are not available in the dev loader.");
public Task<OracleConnection> CreateCmsConnectionAsync(CancellationToken ct = default)
=> throw new NotSupportedException("Oracle CMS connections are not available in the dev loader.");
public Task<OracleConnection> CreateGiwConnectionAsync(CancellationToken ct = default)
=> throw new NotSupportedException("Oracle GIW connections are not available in the dev loader.");
}
@@ -16,14 +16,14 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="LdapForNet" Version="2.7.15" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<PackageReference Include="LdapForNet" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -111,11 +111,13 @@ public class ManualSyncControllerTests
var okResult = (OkObjectResult)result.Result!;
var viewModels = okResult.Value.ShouldBeAssignableTo<List<PipelineInfoViewModel>>()!;
viewModels.Count.ShouldBe(2);
viewModels[0].Name.ShouldBe("WorkOrders");
viewModels[0].Name.ShouldBe("Items");
viewModels[0].SupportedSyncTypes.ShouldContain("mass");
viewModels[0].SupportedSyncTypes.ShouldContain("daily");
viewModels[0].SupportedSyncTypes.ShouldContain("hourly");
viewModels[1].Name.ShouldBe("Items");
viewModels[1].Name.ShouldBe("WorkOrders");
viewModels[1].SupportedSyncTypes.ShouldContain("mass");
viewModels[1].SupportedSyncTypes.ShouldContain("daily");
viewModels[1].SupportedSyncTypes.ShouldContain("hourly");
}
[Fact]
@@ -13,12 +13,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.2.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -12,13 +12,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="RichardSzalay.MockHttp" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
@@ -9,17 +9,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -9,23 +9,23 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Dapper" />
</ItemGroup>
<ItemGroup>
@@ -9,26 +9,26 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Dapper" />
</ItemGroup>
<ItemGroup>
@@ -9,14 +9,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Dapper" />
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
@@ -9,16 +9,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="NPOI" Version="2.7.5" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="NPOI" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
@@ -8,12 +8,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,54 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JdeScoping.Core.Models.Auth;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// API-level smoke tests for the authentication endpoint against the Docker host.
/// Validates the RSA public-key exchange, encrypted login, and session cookie flow.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public class AuthApiSmokeTests
{
/// <summary>
/// Verifies the full login flow: fetch public key, encrypt credentials, POST login, and confirm session via /me.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Create an HttpClient with a CookieContainer for session tracking.
/// 2. GET /api/auth/public-key and verify the PEM response.
/// 3. RSA-encrypt a test login payload using the returned public key.
/// 4. POST /api/auth/login with the encrypted payload and assert HTTP 200.
/// 5. GET /api/auth/me and assert HTTP 200 (session is authenticated).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task AuthApi_Login_WorksAgainstDockerHost()
{
var cookies = new CookieContainer();
using var handler = new HttpClientHandler { CookieContainer = cookies };
using var client = new HttpClient(handler) { BaseAddress = new Uri(UiTestSettings.BaseUrl) };
var key = await client.GetFromJsonAsync<PublicKeyResponse>("api/auth/public-key");
Assert.NotNull(key);
Assert.Contains("BEGIN PUBLIC KEY", key!.PublicKeyPem);
string payload = JsonSerializer.Serialize(new LoginModel { Username = "testuser", Password = "testpass" });
using var rsa = RSA.Create();
rsa.ImportFromPem(key.PublicKeyPem);
byte[] encrypted = rsa.Encrypt(Encoding.UTF8.GetBytes(payload), RSAEncryptionPadding.OaepSHA256);
var login = await client.PostAsJsonAsync("api/auth/login",
new EncryptedLoginRequest(Convert.ToBase64String(encrypted)));
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
var me = await client.GetAsync("api/auth/me");
Assert.Equal(HttpStatusCode.OK, me.StatusCode);
}
}
@@ -0,0 +1,37 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Component Lot" search type (TC-020).
/// Validates search form interaction in smoke mode and full submission with workbook upload in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class ComponentLotSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Component Lot search form submits with an uploaded workbook filter (TC-020).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-020".
/// 3. Select the "Component Lot" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Upload "single_lot.xlsx" to the "Filter By Component Lot" panel (strict only).
/// 6. Click Submit (strict only).
/// 7. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task ComponentLot_SubmitsWithUploadedWorkbook()
{
return RunSearchSubmissionAsync(
UiSearchTypes.ComponentLot,
"MIGRATED-TC-020",
uploads:
[
("Filter By Component Lot", "single_lot.xlsx")
]);
}
}
@@ -0,0 +1,54 @@
using JdeScoping.Ui.Tests.Helpers;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI smoke tests for the Data Sync Requests page.
/// Validates that the page loads and shows action buttons or redirects to search.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class DataSyncPageTests(PlaywrightFixture fixture) : UiTestBase(fixture)
{
/// <summary>
/// Verifies the Data Sync page loads at /data-sync/requests and displays action buttons.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Navigate to the Data Sync Requests page.
/// 2. Assert the URL ends with /data-sync/requests or /search (redirect).
/// 3. If on the data sync page, assert "Data Sync Requests" heading is visible.
/// 4. Assert "New Request" or "Reload Pipelines" button is visible.
/// 5. If redirected, assert "Search Details" is visible.
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task DataSync_Loads()
{
await RunAsync(async page =>
{
await UiNavigationHelper.NavigateToDataSyncAsync(page);
string url = page.Url;
bool onDataSync = url.EndsWith("/data-sync/requests", StringComparison.OrdinalIgnoreCase);
bool redirectedToSearch = url.EndsWith("/search", StringComparison.OrdinalIgnoreCase);
Assert.True(onDataSync || redirectedToSearch, $"Unexpected URL: {url}");
if (onDataSync)
{
await Assertions.Expect(page.GetByText("Data Sync Requests"))
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 });
var newRequestButton =
page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "New Request" });
var reloadButton =
page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Reload Pipelines" });
bool hasAnyControl = await newRequestButton.IsVisibleAsync() || await reloadButton.IsVisibleAsync();
Assert.True(hasAnyControl, "Expected Data Sync action buttons to be visible.");
}
else
{
await Assertions.Expect(page.GetByText("Search Details"))
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 });
}
});
}
}
@@ -0,0 +1,2 @@
global using Xunit;
global using Microsoft.Playwright;
@@ -0,0 +1,45 @@
namespace JdeScoping.Ui.Tests.Helpers;
internal static class UiAuthHelper
{
public static async Task LoginAsync(IPage page, string username = "testuser", string password = "testpass")
{
var loginForm = page.GetByText("Authentication Required");
bool formVisible = await loginForm.IsVisibleAsync();
if (!formVisible) return;
await page.Locator("input[name='Username']").FillAsync(username);
await page.Locator("input[name='Password']").FillAsync(password);
await page.Locator("button[type='submit']:has-text('LOGIN')").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.WaitForTimeoutAsync(3_000);
await page.GotoAsync("/search");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
if (await loginForm.IsVisibleAsync())
{
var notifications = page.Locator(".rz-notification");
int count = await notifications.CountAsync();
var details = new List<string>();
for (var i = 0; i < count; i++)
{
string text = (await notifications.Nth(i).InnerTextAsync()).Trim();
if (!string.IsNullOrWhiteSpace(text)) details.Add(text);
}
throw new InvalidOperationException(
$"Login did not complete. URL={page.Url}. Notifications={string.Join(" | ", details)}");
}
}
public static async Task LogoutAsync(IPage page)
{
var logoutButton = page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Logout" });
if (await logoutButton.IsVisibleAsync())
{
await logoutButton.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}
}
}
@@ -0,0 +1,102 @@
namespace JdeScoping.Ui.Tests.Helpers;
internal static class UiNavigationHelper
{
public static async Task NavigateToSearchPageAsync(IPage page)
{
await page.GotoAsync("/search");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await UiAuthHelper.LoginAsync(page);
await EnsureAuthenticatedOrThrowAsync(page);
await WaitForBlazorReadyAsync(page);
}
public static async Task NavigateToSearchesDashboardAsync(IPage page)
{
await page.GotoAsync("/searches");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await UiAuthHelper.LoginAsync(page);
await EnsureAuthenticatedOrThrowAsync(page);
await WaitForBlazorReadyAsync(page);
}
public static async Task NavigateToQueueAsync(IPage page)
{
await page.GotoAsync("/search/queue");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await UiAuthHelper.LoginAsync(page);
await EnsureAuthenticatedOrThrowAsync(page);
await WaitForBlazorReadyAsync(page);
}
public static async Task NavigateToRefreshStatusAsync(IPage page)
{
await page.GotoAsync("/refresh-status");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await UiAuthHelper.LoginAsync(page);
await EnsureAuthenticatedOrThrowAsync(page);
await WaitForBlazorReadyAsync(page);
}
public static async Task NavigateToDataSyncAsync(IPage page)
{
await page.GotoAsync("/data-sync/requests");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await UiAuthHelper.LoginAsync(page);
await EnsureAuthenticatedOrThrowAsync(page);
await WaitForBlazorReadyAsync(page);
}
public static async Task NavigateToLoginAsync(IPage page)
{
await page.GotoAsync("/login");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}
private static async Task WaitForBlazorReadyAsync(IPage page)
{
var timeoutMs = 15_000;
try
{
await page.Locator(".rz-dropdown").First.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = timeoutMs
});
return;
}
catch (PlaywrightException)
{
// Try additional readiness markers.
}
try
{
await page.Locator(".rz-data-grid").First.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = timeoutMs
});
return;
}
catch (PlaywrightException)
{
// Try text marker as final fallback.
}
await page.GetByText("Search Details").First.WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = timeoutMs
});
await page.WaitForTimeoutAsync(1_000);
}
private static async Task EnsureAuthenticatedOrThrowAsync(IPage page)
{
var meResponse = await page.Context.APIRequest.GetAsync("/api/auth/me");
if (meResponse.Status != 200)
throw new InvalidOperationException(
$"UI test host did not establish authenticated session after login. /api/auth/me status={meResponse.Status}.");
}
}
@@ -0,0 +1,63 @@
namespace JdeScoping.Ui.Tests.Helpers;
internal static class UiSearchFormHelper
{
public static async Task SelectSearchTypeAsync(IPage page, string searchType)
{
await page.Locator(".rz-dropdown").First.ClickAsync();
await page.GetByRole(AriaRole.Option, new PageGetByRoleOptions { Name = searchType, Exact = true })
.ClickAsync();
await page.WaitForTimeoutAsync(500);
}
public static Task EnterSearchNameAsync(IPage page, string name)
{
return page.Locator("input[placeholder=' ']").First.FillAsync(name);
}
public static async Task SetDateRangeAsync(IPage page, string minimumMmDdYyyy, string maximumMmDdYyyy)
{
await page.Locator("input[name='MinimumDt']").FillAsync(minimumMmDdYyyy);
await page.Locator("input[name='MaximumDt']").FillAsync(maximumMmDdYyyy);
}
public static async Task AddAutocompleteItemAsync(IPage page, string panelHeader, string value)
{
var panel = page.Locator($".rz-card:has-text('{panelHeader}')");
await panel.Locator(".rz-autocomplete input").First.FillAsync(value);
await page.WaitForTimeoutAsync(500);
var listItem = page.Locator(".rz-autocomplete-list .rz-autocomplete-list-item").First;
if (await listItem.IsVisibleAsync()) await listItem.ClickAsync();
await panel.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Add" }).ClickAsync();
await page.WaitForTimeoutAsync(250);
}
public static async Task UploadFileAsync(IPage page, string panelHeader, string filePath)
{
var panel = page.Locator($".rz-card:has-text('{panelHeader}')");
await panel.Locator("input[type='file']").First.SetInputFilesAsync(filePath);
await page.WaitForTimeoutAsync(1_500);
}
public static async Task SubmitSearchAsync(IPage page)
{
await page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Submit" }).First.ClickAsync();
await page.GetByText("Confirm Submit").WaitForAsync(new LocatorWaitForOptions
{
State = WaitForSelectorState.Visible,
Timeout = 10_000
});
await page.Locator(".rz-dialog-wrapper button")
.GetByText("Submit", new LocatorGetByTextOptions { Exact = true }).ClickAsync();
await page.WaitForTimeoutAsync(1_000);
}
public static async Task AssertNoErrorNotificationAsync(IPage page)
{
var error = page.Locator(".rz-notification-error");
await Assertions.Expect(error).Not
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 5_000 });
}
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Microsoft.Playwright"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="xunit"/>
<PackageReference Include="xunit.runner.visualstudio"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\JdeScoping.Core\JdeScoping.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<None Include="TestData/**/*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
using JdeScoping.Ui.Tests.Helpers;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the Login page.
/// Validates that the login form renders, credentials are accepted, and logout revokes the session.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class LoginPageTests(PlaywrightFixture fixture) : UiTestBase(fixture)
{
/// <summary>
/// Verifies the login page renders, credentials authenticate the user, and logout revokes the session.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Navigate to the login page.
/// 2. Assert the page title contains "Login - JDE Scoping Tool".
/// 3. Submit test credentials via UiAuthHelper.LoginAsync.
/// 4. Assert the user sees the Logout button or remains on the login view.
/// 5. Invoke UiAuthHelper.LogoutAsync.
/// 6. GET /api/auth/me and assert HTTP 401 (session revoked).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task LoginPage_AllowsLoginAndLogout()
{
await RunAsync(async page =>
{
await UiNavigationHelper.NavigateToLoginAsync(page);
await Assertions.Expect(page).ToHaveTitleAsync(new Regex("Login - JDE Scoping Tool"));
await UiAuthHelper.LoginAsync(page);
var loggedOutView = page.GetByText("Authentication Required");
var logoutButton = page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Logout" });
bool authenticated = await logoutButton.IsVisibleAsync();
bool stillOnLogin = await loggedOutView.IsVisibleAsync();
Assert.True(authenticated || stillOnLogin);
await UiAuthHelper.LogoutAsync(page);
var meAfterLogout = await page.Context.APIRequest.GetAsync("/api/auth/me");
Assert.Equal(401, meAfterLogout.Status);
});
}
}
+29
View File
@@ -0,0 +1,29 @@
# JdeScoping.Ui.Tests
Playwright-for-.NET UI tests migrated from the legacy TypeScript Playwright suite.
## Preconditions
- Docker host container is already running via `/Users/dohertj2/Desktop/JdeScopingTool/NEW/deploy/docker/deploy-host.sh`
- App reachable at `http://localhost:5294` (or set `JDESCOPING_UI_BASE_URL`)
## First-time setup
```bash
cd /Users/dohertj2/Desktop/JdeScopingTool/NEW/tests/JdeScoping.Ui.Tests
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
```
## Run tests
```bash
cd /Users/dohertj2/Desktop/JdeScopingTool/NEW
dotnet test tests/JdeScoping.Ui.Tests/JdeScoping.Ui.Tests.csproj --filter "Category=RequiresDockerHost"
```
Run headed mode:
```bash
JDESCOPING_UI_HEADED=true dotnet test tests/JdeScoping.Ui.Tests/JdeScoping.Ui.Tests.csproj
```
@@ -0,0 +1,43 @@
using JdeScoping.Ui.Tests.Helpers;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI smoke tests for the Cache Refresh Status page.
/// Validates that the page loads and shows the "Cache Refresh Status" heading or redirects to search.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class RefreshStatusPageTests(PlaywrightFixture fixture) : UiTestBase(fixture)
{
/// <summary>
/// Verifies the Refresh Status page loads at /refresh-status and displays the expected heading.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Navigate to the Refresh Status page.
/// 2. Assert the URL ends with /refresh-status or /search (redirect).
/// 3. If on the refresh page, assert "Cache Refresh Status" heading is visible.
/// 4. If redirected, assert "Search Details" is visible.
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task RefreshStatus_Loads()
{
await RunAsync(async page =>
{
await UiNavigationHelper.NavigateToRefreshStatusAsync(page);
string url = page.Url;
bool onRefreshStatus = url.EndsWith("/refresh-status", StringComparison.OrdinalIgnoreCase);
bool redirectedToSearch = url.EndsWith("/search", StringComparison.OrdinalIgnoreCase);
Assert.True(onRefreshStatus || redirectedToSearch, $"Unexpected URL: {url}");
if (onRefreshStatus)
await Assertions.Expect(page.GetByText("Cache Refresh Status"))
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 });
else
await Assertions.Expect(page.GetByText("Search Details"))
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 });
});
}
}
@@ -0,0 +1,36 @@
using JdeScoping.Ui.Tests.Helpers;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI smoke tests for the Search page.
/// Validates that the search form loads with primary controls visible and no error notifications.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class SearchPageTests(PlaywrightFixture fixture) : UiTestBase(fixture)
{
/// <summary>
/// Verifies the search page loads and displays the "Search Details" heading, Submit button, and no errors.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Navigate to the search page.
/// 2. Assert the "Search Details" text is visible.
/// 3. Assert the Submit button is visible.
/// 4. Assert no error notification is present.
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task SearchPage_LoadsAndShowsPrimaryControls()
{
await RunAsync(async page =>
{
await UiNavigationHelper.NavigateToSearchPageAsync(page);
await Assertions.Expect(page.GetByText("Search Details")).ToBeVisibleAsync();
await Assertions.Expect(page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Submit" }).First)
.ToBeVisibleAsync();
await UiSearchFormHelper.AssertNoErrorNotificationAsync(page);
});
}
}
@@ -0,0 +1,51 @@
using JdeScoping.Ui.Tests.Helpers;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI smoke tests for the Search Queue page.
/// Validates that the queue page loads and shows a data grid, alert, or loading indicator.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class SearchQueuePageTests(PlaywrightFixture fixture) : UiTestBase(fixture)
{
/// <summary>
/// Verifies the Search Queue page loads at /search/queue and displays queue content or a redirect.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Navigate to the Search Queue page.
/// 2. Assert the URL ends with /search/queue or /search (redirect).
/// 3. If on the queue page, assert "Search Queue" heading and grid/alert/loading indicator are visible.
/// 4. If redirected, assert "Search Details" is visible.
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task SearchQueue_Loads()
{
await RunAsync(async page =>
{
await UiNavigationHelper.NavigateToQueueAsync(page);
string url = page.Url;
bool onQueue = url.EndsWith("/search/queue", StringComparison.OrdinalIgnoreCase);
bool redirectedToSearch = url.EndsWith("/search", StringComparison.OrdinalIgnoreCase);
Assert.True(onQueue || redirectedToSearch, $"Unexpected URL: {url}");
if (onQueue)
{
await Assertions.Expect(page.GetByText("Search Queue"))
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 });
bool hasGrid = await page.Locator(".rz-data-grid").First.IsVisibleAsync();
bool hasAlert = await page.Locator(".rz-alert").First.IsVisibleAsync();
bool hasLoading = await page.GetByText("Loading queue").IsVisibleAsync();
Assert.True(hasGrid || hasAlert || hasLoading, "Expected queue grid, alert, or loading indicator.");
}
else
{
await Assertions.Expect(page.GetByText("Search Details"))
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 15_000 });
}
});
}
}
@@ -0,0 +1,42 @@
using JdeScoping.Ui.Tests.Helpers;
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI smoke tests for the Searches Dashboard page.
/// Validates that the dashboard loads at the expected URL and shows a heading or data grid.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class SearchesDashboardPageTests(PlaywrightFixture fixture) : UiTestBase(fixture)
{
/// <summary>
/// Verifies the Searches Dashboard page loads at /searches and displays a heading or data grid.
/// </summary>
/// <remarks>
/// Steps:
/// 1. Navigate to the Searches Dashboard page.
/// 2. Assert the URL ends with /searches, /search, or /.
/// 3. Assert that "Searches Dashboard", "Search Details", or the Radzen data grid is visible.
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public async Task SearchesDashboard_Loads()
{
await RunAsync(async page =>
{
await UiNavigationHelper.NavigateToSearchesDashboardAsync(page);
string url = page.Url;
Assert.True(
url.EndsWith("/searches", StringComparison.OrdinalIgnoreCase) ||
url.EndsWith("/search", StringComparison.OrdinalIgnoreCase) ||
url.EndsWith("/", StringComparison.OrdinalIgnoreCase),
$"Unexpected URL: {url}");
bool hasSearchesHeading = await page.GetByText("Searches Dashboard").IsVisibleAsync();
bool hasSearchDetails = await page.GetByText("Search Details").IsVisibleAsync();
bool hasGrid = await page.Locator(".rz-data-grid").First.IsVisibleAsync();
Assert.True(hasSearchesHeading || hasSearchDetails || hasGrid);
});
}
}
@@ -0,0 +1,26 @@
namespace JdeScoping.Ui.Tests.Support;
public sealed class PlaywrightFixture : IAsyncLifetime
{
private IBrowser? _browser;
private IPlaywright? _playwright;
public IBrowser Browser => _browser ?? throw new InvalidOperationException("Browser is not initialized.");
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = UiTestSettings.Headless,
Args = ["--no-sandbox", "--disable-dev-shm-usage"]
});
}
public async Task DisposeAsync()
{
if (_browser is not null) await _browser.CloseAsync();
_playwright?.Dispose();
}
}
@@ -0,0 +1,46 @@
using JdeScoping.Ui.Tests.Helpers;
namespace JdeScoping.Ui.Tests.Support;
public abstract class SearchFlowTestBase(PlaywrightFixture fixture) : UiTestBase(fixture)
{
private static bool StrictMode =>
string.Equals(Environment.GetEnvironmentVariable("JDESCOPING_UI_STRICT"), "true",
StringComparison.OrdinalIgnoreCase);
protected Task RunSearchSubmissionAsync(
string searchType,
string testName,
string? minDate = null,
string? maxDate = null,
(string PanelHeader, string Value)[]? autocompleteItems = null,
(string PanelHeader, string FileName)[]? uploads = null)
{
return RunAsync(async page =>
{
await UiNavigationHelper.NavigateToSearchPageAsync(page);
await UiSearchFormHelper.EnterSearchNameAsync(page, testName);
await UiSearchFormHelper.SelectSearchTypeAsync(page, searchType);
await Assertions.Expect(page.Locator(".rz-dropdown-label").First).ToContainTextAsync(searchType);
if (!StrictMode)
// Default mode is smoke-only against local docker where source systems can be offline.
return;
if (!string.IsNullOrWhiteSpace(minDate) && !string.IsNullOrWhiteSpace(maxDate))
await UiSearchFormHelper.SetDateRangeAsync(page, minDate, maxDate);
if (autocompleteItems is not null)
foreach (var item in autocompleteItems)
await UiSearchFormHelper.AddAutocompleteItemAsync(page, item.PanelHeader, item.Value);
if (uploads is not null)
foreach (var upload in uploads)
await UiSearchFormHelper.UploadFileAsync(page, upload.PanelHeader,
TestDataPaths.Get(upload.FileName));
await UiSearchFormHelper.SubmitSearchAsync(page);
await UiSearchFormHelper.AssertNoErrorNotificationAsync(page);
});
}
}
@@ -0,0 +1,9 @@
namespace JdeScoping.Ui.Tests.Support;
internal static class TestDataPaths
{
public static string Get(string fileName)
{
return Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
}
}
@@ -0,0 +1,21 @@
namespace JdeScoping.Ui.Tests.Support;
internal static class UiSearchTypes
{
public const string WorkOrder = "Work Order";
public const string ComponentLot = "Component Lot";
public const string TimeSpanProfitCenter = "Time Span + Profit Center";
public const string TimeSpanWorkCenter = "Time Span + Work Center";
public const string TimeSpanOperator = "Time Span + Operator";
public const string TimeSpanPcItem = "Time Span + Profit Center + Item Number";
public const string TimeSpanPcPartOp = "Time Span + Profit Center + Item/Operation/MIS";
public const string TimeSpanPcWoPartOp = "Time Span + Profit Center + Work Order + Item/Operation/MIS";
public const string TimeSpanPcExtractMis = "Time Span + Profit Center + Extract MIS";
public const string TimeSpanWcItem = "Time Span + Work Center + Item Number";
public const string TimeSpanWcExtractMis = "Time Span + Work Center + Extract MIS";
public const string TimeSpanWcPartOp = "Time Span + Work Center + Item/Operation/MIS";
public const string TimeSpanWcWoPartOp = "Time Span + Work Center + Work Order + Item/Operation/MIS";
public const string TimeSpanItem = "Time Span + Item Number";
public const string TimeSpanWcOperator = "Time Span + Work Center + Operator";
public const string TimeSpanPcOperator = "Time Span + Profit Center + Operator";
}
@@ -0,0 +1,27 @@
namespace JdeScoping.Ui.Tests.Support;
[Collection(UiTestCollection.Name)]
public abstract class UiTestBase
{
private readonly PlaywrightFixture _fixture;
protected UiTestBase(PlaywrightFixture fixture)
{
_fixture = fixture;
}
protected async Task RunAsync(Func<IPage, Task> action)
{
await using var context = await _fixture.Browser.NewContextAsync(new BrowserNewContextOptions
{
BaseURL = UiTestSettings.BaseUrl,
IgnoreHTTPSErrors = true
});
var page = await context.NewPageAsync();
page.SetDefaultTimeout(30_000);
page.SetDefaultNavigationTimeout(120_000);
await action(page);
}
}
@@ -0,0 +1,7 @@
namespace JdeScoping.Ui.Tests.Support;
[CollectionDefinition(Name)]
public sealed class UiTestCollection : ICollectionFixture<PlaywrightFixture>
{
public const string Name = "UiTests";
}
@@ -0,0 +1,12 @@
namespace JdeScoping.Ui.Tests.Support;
internal static class UiTestSettings
{
public static string BaseUrl =>
Environment.GetEnvironmentVariable("JDESCOPING_UI_BASE_URL")
?? "http://localhost:5294";
public static bool Headless =>
!string.Equals(Environment.GetEnvironmentVariable("JDESCOPING_UI_HEADED"), "true",
StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,281 @@
{
"profitCenters": [
{
"code": "1AM",
"description": "Profit Center 1AM"
},
{
"code": "1BM",
"description": "Profit Center 1BM"
},
{
"code": "1CM",
"description": "Profit Center 1CM"
},
{
"code": "1PM",
"description": "Profit Center 1PM"
},
{
"code": "2DM",
"description": "Profit Center 2DM"
},
{
"code": "2SM",
"description": "Profit Center 2SM"
},
{
"code": "3TM",
"description": "Profit Center 3TM"
},
{
"code": "4IM",
"description": "Profit Center 4IM"
},
{
"code": "5SM",
"description": "Profit Center 5SM"
}
],
"workCenters": [
{
"code": "WC001",
"description": "Work Center 001"
},
{
"code": "WC002",
"description": "Work Center 002"
},
{
"code": "WC003",
"description": "Work Center 003"
}
],
"operators": [
{
"userId": "ADAMSSN",
"fullName": "Adams, S N"
},
{
"userId": "AGNEWA",
"fullName": "Agnew, A"
},
{
"userId": "AGNEWL",
"fullName": "Agnew, L"
},
{
"userId": "ALASMARB",
"fullName": "Alasmar, B"
},
{
"userId": "ALEXIUCG",
"fullName": "Alexiuc, G"
},
{
"userId": "ALLENHY",
"fullName": "Allen, H Y"
},
{
"userId": "ALLENNI",
"fullName": "Allen, N I"
},
{
"userId": "ALURUM",
"fullName": "Aluru, M"
},
{
"userId": "ALVESM1",
"fullName": "Alves, M"
},
{
"userId": "APONTEVE",
"fullName": "Aponte, V E"
}
],
"workOrders": [
{
"workOrderNumber": "99059700",
"itemNumber": "00598004702"
},
{
"workOrderNumber": "99002260",
"itemNumber": "82070000028"
},
{
"workOrderNumber": "99002259",
"itemNumber": "82070000027"
},
{
"workOrderNumber": "99002258",
"itemNumber": "82070000019"
},
{
"workOrderNumber": "99002257",
"itemNumber": "82070000018"
},
{
"workOrderNumber": "99002256",
"itemNumber": "82070000017"
},
{
"workOrderNumber": "99002255",
"itemNumber": "00855140333"
},
{
"workOrderNumber": "99002254",
"itemNumber": "00855480834"
},
{
"workOrderNumber": "99002252",
"itemNumber": "82070000016"
},
{
"workOrderNumber": "99002251",
"itemNumber": "00855910448"
},
{
"workOrderNumber": "99002250",
"itemNumber": "82070000015"
},
{
"workOrderNumber": "99002249",
"itemNumber": "00855480834"
},
{
"workOrderNumber": "99002248",
"itemNumber": "00855910446"
},
{
"workOrderNumber": "99002247",
"itemNumber": "00855910447"
},
{
"workOrderNumber": "99002246",
"itemNumber": "82900171601"
}
],
"itemNumbers": [
{
"itemNumber": "00598004702",
"description": "Item 598004702"
},
{
"itemNumber": "82070000028",
"description": "Item 82070000028"
},
{
"itemNumber": "82070000027",
"description": "Item 82070000027"
},
{
"itemNumber": "00855140333",
"description": "Item 855140333"
},
{
"itemNumber": "00855480834",
"description": "Item 855480834"
}
],
"componentLots": [
{
"lotNumber": "LOT001",
"itemNumber": "00598004702"
},
{
"lotNumber": "LOT002",
"itemNumber": "82070000028"
},
{
"lotNumber": "LOT003",
"itemNumber": "82070000027"
}
],
"partOperations": [
{
"itemNumber": "00598004702",
"operationNumber": "100",
"misNumber": "MIS001",
"misRevision": "A"
},
{
"itemNumber": "00598004702",
"operationNumber": "200",
"misNumber": "MIS002",
"misRevision": "B"
},
{
"itemNumber": "82070000028",
"operationNumber": "100",
"misNumber": "MIS003",
"misRevision": "A"
}
],
"dateRanges": {
"recent": {
"min": "2020-01-01",
"max": "2020-09-01",
"description": "Recent data range"
},
"midRange": {
"min": "2018-01-01",
"max": "2019-12-31",
"description": "Mid-range data"
},
"historical": {
"min": "2016-01-01",
"max": "2017-12-31",
"description": "Historical data"
},
"sameDay": {
"min": "2020-06-15",
"max": "2020-06-15",
"description": "Single day range"
},
"startBoundary": {
"min": "1905-01-20",
"max": "1905-12-31",
"description": "Start of data range"
},
"endBoundary": {
"min": "2020-08-01",
"max": "2020-09-01",
"description": "End of data range"
}
},
"invalidData": {
"workOrders": {
"invalidFormat": "ABC123XYZ",
"specialChars": "99059700!@#",
"empty": "",
"whitespace": " "
},
"profitCenters": {
"invalid": "INVALID",
"specialChars": "1AM!@#",
"empty": "",
"tooLong": "1AMEXTRALONG"
},
"dates": {
"invalidFormat": "31-12-2020",
"future": {
"min": "2025-01-01",
"max": "2025-12-31"
},
"reversed": {
"min": "2020-09-01",
"max": "2020-01-01"
}
}
},
"testCredentials": {
"validUser": {
"username": "testuser",
"password": "testpass"
},
"invalidUser": {
"username": "invaliduser",
"password": "wrongpassword"
}
}
}
@@ -0,0 +1,40 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Item Number" search type (TC-140).
/// Validates search form interaction in smoke mode and full submission with workbook upload in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanItemNumberSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Item Number search form submits with an uploaded workbook filter (TC-140).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-140".
/// 3. Select the "Time Span + Item Number" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Upload "single_item.xlsx" to the "Filter by Item Number" panel (strict only).
/// 7. Click Submit (strict only).
/// 8. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanItemNumber_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanItem,
"MIGRATED-TC-140",
"01/01/2019",
"12/31/2019",
uploads:
[
("Filter by Item Number", "single_item.xlsx")
]);
}
}
@@ -0,0 +1,39 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Operator" search type (TC-050).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanOperatorSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Operator search form submits with autocomplete filter (TC-050).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-050".
/// 3. Select the "Time Span + Operator" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "ADAMSSN" to the "Filter by Operator" panel (strict only).
/// 7. Click Submit (strict only).
/// 8. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanOperator_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanOperator,
"MIGRATED-TC-050",
"01/01/2019",
"12/31/2019",
[
("Filter by Operator", "ADAMSSN")
]);
}
}
@@ -0,0 +1,39 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Profit Center + Extract MIS" search type (TC-090).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanPcExtractMisSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Profit Center + Extract MIS search form submits with autocomplete filter (TC-090).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-090".
/// 3. Select the "Time Span + Profit Center + Extract MIS" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "1AM" to the "Filter by Profit Center" panel (strict only).
/// 7. Click Submit (strict only).
/// 8. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanPcExtractMis_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanPcExtractMis,
"MIGRATED-TC-090",
"01/01/2019",
"12/31/2019",
[
("Filter by Profit Center", "1AM")
]);
}
}
@@ -0,0 +1,43 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Profit Center + Item Number" search type (TC-060).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanPcItemSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Profit Center + Item Number search form submits with autocomplete and upload (TC-060).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-060".
/// 3. Select the "Time Span + Profit Center + Item Number" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "1AM" to the "Filter by Profit Center" panel (strict only).
/// 7. Upload "single_item.xlsx" to the "Filter by Item Number" panel (strict only).
/// 8. Click Submit (strict only).
/// 9. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanPcItem_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanPcItem,
"MIGRATED-TC-060",
"01/01/2019",
"12/31/2019",
[
("Filter by Profit Center", "1AM")
],
[
("Filter by Item Number", "single_item.xlsx")
]);
}
}
@@ -0,0 +1,41 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Profit Center + Operator" search type (TC-160).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanPcOperatorSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Profit Center + Operator search form submits with autocomplete filters (TC-160).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-160".
/// 3. Select the "Time Span + Profit Center + Operator" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "1AM" to the "Filter by Profit Center" panel (strict only).
/// 7. Add autocomplete value "ADAMSSN" to the "Filter by Operator" panel (strict only).
/// 8. Click Submit (strict only).
/// 9. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanPcOperator_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanPcOperator,
"MIGRATED-TC-160",
"01/01/2019",
"12/31/2019",
[
("Filter by Profit Center", "1AM"),
("Filter by Operator", "ADAMSSN")
]);
}
}
@@ -0,0 +1,43 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Profit Center + Item/Operation/MIS" search type (TC-070).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanPcPartOpSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Profit Center + Item/Operation/MIS search form submits with autocomplete and upload (TC-070).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-070".
/// 3. Select the "Time Span + Profit Center + Item/Operation/MIS" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "1AM" to the "Filter by Profit Center" panel (strict only).
/// 7. Upload "single_operation.xlsx" to the "Filter By Item/Operation/MIS" panel (strict only).
/// 8. Click Submit (strict only).
/// 9. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanPcPartOp_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanPcPartOp,
"MIGRATED-TC-070",
"01/01/2019",
"12/31/2019",
[
("Filter by Profit Center", "1AM")
],
[
("Filter By Item/Operation/MIS", "single_operation.xlsx")
]);
}
}
@@ -0,0 +1,45 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Profit Center + Work Order + Item/Operation/MIS" search type (TC-080).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanPcWoPartOpSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + PC + Work Order + Item/Op/MIS search form submits with autocomplete and uploads (TC-080).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-080".
/// 3. Select the "Time Span + Profit Center + Work Order + Item/Operation/MIS" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "1AM" to the "Filter by Profit Center" panel (strict only).
/// 7. Upload "single_workorder.xlsx" to the "Filter by Work Order" panel (strict only).
/// 8. Upload "single_operation.xlsx" to the "Filter By Item/Operation/MIS" panel (strict only).
/// 9. Click Submit (strict only).
/// 10. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanPcWoPartOp_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanPcWoPartOp,
"MIGRATED-TC-080",
"01/01/2019",
"12/31/2019",
[
("Filter by Profit Center", "1AM")
],
[
("Filter by Work Order", "single_workorder.xlsx"),
("Filter By Item/Operation/MIS", "single_operation.xlsx")
]);
}
}
@@ -0,0 +1,39 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Profit Center" search type (TC-030).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanProfitCenterSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Profit Center search form submits with autocomplete filter (TC-030).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-030".
/// 3. Select the "Time Span + Profit Center" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "1AM" to the "Filter by Profit Center" panel (strict only).
/// 7. Click Submit (strict only).
/// 8. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanProfitCenter_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanProfitCenter,
"MIGRATED-TC-030",
"01/01/2019",
"12/31/2019",
[
("Filter by Profit Center", "1AM")
]);
}
}
@@ -0,0 +1,39 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Work Center + Extract MIS" search type (TC-110).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanWcExtractMisSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Work Center + Extract MIS search form submits with autocomplete filter (TC-110).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-110".
/// 3. Select the "Time Span + Work Center + Extract MIS" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "0083AS" to the "Filter by Work Center" panel (strict only).
/// 7. Click Submit (strict only).
/// 8. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanWcExtractMis_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanWcExtractMis,
"MIGRATED-TC-110",
"01/01/2019",
"12/31/2019",
[
("Filter by Work Center", "0083AS")
]);
}
}
@@ -0,0 +1,43 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Work Center + Item Number" search type (TC-100).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanWcItemSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Work Center + Item Number search form submits with autocomplete and upload (TC-100).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-100".
/// 3. Select the "Time Span + Work Center + Item Number" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "0083AS" to the "Filter by Work Center" panel (strict only).
/// 7. Upload "single_item.xlsx" to the "Filter by Item Number" panel (strict only).
/// 8. Click Submit (strict only).
/// 9. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanWcItem_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanWcItem,
"MIGRATED-TC-100",
"01/01/2019",
"12/31/2019",
[
("Filter by Work Center", "0083AS")
],
[
("Filter by Item Number", "single_item.xlsx")
]);
}
}
@@ -0,0 +1,41 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Work Center + Operator" search type (TC-150).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanWcOperatorSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Work Center + Operator search form submits with autocomplete filters (TC-150).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-150".
/// 3. Select the "Time Span + Work Center + Operator" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "0083AS" to the "Filter by Work Center" panel (strict only).
/// 7. Add autocomplete value "ADAMSSN" to the "Filter by Operator" panel (strict only).
/// 8. Click Submit (strict only).
/// 9. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanWcOperator_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanWcOperator,
"MIGRATED-TC-150",
"01/01/2019",
"12/31/2019",
[
("Filter by Work Center", "0083AS"),
("Filter by Operator", "ADAMSSN")
]);
}
}
@@ -0,0 +1,43 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Work Center + Item/Operation/MIS" search type (TC-120).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanWcPartOpSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Work Center + Item/Operation/MIS search form submits with autocomplete and upload (TC-120).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-120".
/// 3. Select the "Time Span + Work Center + Item/Operation/MIS" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "0083AS" to the "Filter by Work Center" panel (strict only).
/// 7. Upload "single_operation.xlsx" to the "Filter By Item/Operation/MIS" panel (strict only).
/// 8. Click Submit (strict only).
/// 9. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanWcPartOp_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanWcPartOp,
"MIGRATED-TC-120",
"01/01/2019",
"12/31/2019",
[
("Filter by Work Center", "0083AS")
],
[
("Filter By Item/Operation/MIS", "single_operation.xlsx")
]);
}
}
@@ -0,0 +1,45 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Work Center + Work Order + Item/Operation/MIS" search type (TC-130).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanWcWoPartOpSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + WC + Work Order + Item/Op/MIS search form submits with autocomplete and uploads (TC-130).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-130".
/// 3. Select the "Time Span + Work Center + Work Order + Item/Operation/MIS" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "0083AS" to the "Filter by Work Center" panel (strict only).
/// 7. Upload "single_workorder.xlsx" to the "Filter by Work Order" panel (strict only).
/// 8. Upload "single_operation.xlsx" to the "Filter By Item/Operation/MIS" panel (strict only).
/// 9. Click Submit (strict only).
/// 10. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanWcWoPartOp_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanWcWoPartOp,
"MIGRATED-TC-130",
"01/01/2019",
"12/31/2019",
[
("Filter by Work Center", "0083AS")
],
[
("Filter by Work Order", "single_workorder.xlsx"),
("Filter By Item/Operation/MIS", "single_operation.xlsx")
]);
}
}
@@ -0,0 +1,39 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Time Span + Work Center" search type (TC-040).
/// Validates search form interaction in smoke mode and full submission in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class TimeSpanWorkCenterSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Time Span + Work Center search form submits with autocomplete filter (TC-040).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-040".
/// 3. Select the "Time Span + Work Center" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Set the date range to 01/01/2019 12/31/2019 (strict only).
/// 6. Add autocomplete value "0083AS" to the "Filter by Work Center" panel (strict only).
/// 7. Click Submit (strict only).
/// 8. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task TimeSpanWorkCenter_Submits()
{
return RunSearchSubmissionAsync(
UiSearchTypes.TimeSpanWorkCenter,
"MIGRATED-TC-040",
"01/01/2019",
"12/31/2019",
[
("Filter by Work Center", "0083AS")
]);
}
}
@@ -0,0 +1,37 @@
using JdeScoping.Ui.Tests.Support;
namespace JdeScoping.Ui.Tests;
/// <summary>
/// Playwright UI tests for the "Work Order" search type (TC-010).
/// Validates search form interaction in smoke mode and full submission with workbook upload in strict mode.
/// Requires a running Docker host (Category: RequiresDockerHost).
/// </summary>
public sealed class WorkOrderSearchTests(PlaywrightFixture fixture) : SearchFlowTestBase(fixture)
{
/// <summary>
/// Verifies the Work Order search form submits with an uploaded workbook filter (TC-010).
/// </summary>
/// <remarks>
/// Steps (smoke mode stops after step 4; strict mode runs all steps):
/// 1. Navigate to the search page.
/// 2. Enter the search name "MIGRATED-TC-010".
/// 3. Select the "Work Order" search type from the dropdown.
/// 4. Verify the dropdown displays the selected type.
/// 5. Upload "single_workorder.xlsx" to the "Filter by Work Order" panel (strict only).
/// 6. Click Submit (strict only).
/// 7. Assert no error notification is present (strict only).
/// </remarks>
[Fact]
[Trait("Category", "RequiresDockerHost")]
public Task WorkOrder_SubmitsWithUploadedWorkbook()
{
return RunSearchSubmissionAsync(
UiSearchTypes.WorkOrder,
"MIGRATED-TC-010",
uploads:
[
("Filter by Work Order", "single_workorder.xlsx")
]);
}
}

Some files were not shown because too many files have changed in this diff Show More