diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index bdb4b7b..30acbb7 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -26,26 +26,44 @@ jobs: with: fetch-depth: 0 - - name: Setup .NET 9 SDK + - name: Setup .NET 10 SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x cache: true cache-dependency-path: | **/*.sln + **/*.slnx **/*.csproj - - name: Restore - run: dotnet restore + - name: Restore (each csproj) + shell: pwsh + run: | + $projects = Get-ChildItem -Recurse -Filter *.csproj -File + if (-not $projects) { Write-Host "No csproj files found"; exit 1 } + foreach ($p in $projects) { + echo "Restoring $($p.FullName)" + dotnet restore --nologo "$($p.FullName)" + } - - name: Build - run: dotnet build --configuration Release --no-restore /p:ContinuousIntegrationBuild=true + - name: Build (Release) + shell: pwsh + run: | + $projects = Get-ChildItem -Recurse -Filter *.csproj -File | ForEach-Object { $_.FullName } + foreach ($p in $projects) { + echo "Building $p" + dotnet build "$p" --configuration Release --no-restore /p:ContinuousIntegrationBuild=true + } - name: Test if: ${{ hashFiles('**/*Tests.csproj') != '' || hashFiles('**/*Test.csproj') != '' }} - run: >- - dotnet test --configuration Release --no-build --verbosity normal - --collect:"XPlat Code Coverage" --logger "trx;LogFileName=test_results.trx" + shell: pwsh + run: | + $testProjects = Get-ChildItem -Recurse -Include *Tests.csproj,*Test.csproj -File | ForEach-Object { $_.FullName } + foreach ($tp in $testProjects) { + echo "Testing $tp" + dotnet test "$tp" --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" --logger "trx;LogFileName=test_results.trx" + } - name: Upload test results if: ${{ always() && (hashFiles('**/*Tests.csproj') != '' || hashFiles('**/*Test.csproj') != '' ) }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5cea8ba..ee60b50 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,10 +29,15 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET 9 SDK + - name: Setup .NET 10 SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x + cache: true + cache-dependency-path: | + **/*.sln + **/*.slnx + **/*.csproj - name: Initialize CodeQL uses: github/codeql-action/init@v3 @@ -44,9 +49,18 @@ jobs: - name: Manual build fallback if: failure() + shell: pwsh run: | - dotnet restore - dotnet build --configuration Release --no-restore + $projects = Get-ChildItem -Recurse -Filter *.csproj -File + if (-not $projects) { Write-Host "No csproj files found"; exit 1 } + foreach ($p in $projects) { + echo "Restoring $($p.FullName)" + dotnet restore --nologo "$($p.FullName)" + } + foreach ($p in $projects) { + echo "Building $($p.FullName)" + dotnet build "$($p.FullName)" --configuration Release --no-restore /p:ContinuousIntegrationBuild=true + } - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/Services/Layering/StripFillGenerationStrategy.cs b/Services/Layering/StripFillGenerationStrategy.cs index 5a536b8..a9e04b3 100644 --- a/Services/Layering/StripFillGenerationStrategy.cs +++ b/Services/Layering/StripFillGenerationStrategy.cs @@ -103,7 +103,7 @@ public List Generate(List skus, SupportSurface supportSurface, Gener double util = usedArea / area; string desc = $"rows={nrows} seq=" + string.Join(",", seq.Select(v => $"{v.sref.Name}:{v.w}x{v.h}")); - string lid = $"strip_r{nrows}_" + string.Join("_", seq.Select(v => $"{v.sref.Name.Replace(' ', '_')}{v.w}x{v.h}")); + string lid = $"strip_r{nrows}"; int layerHeight = seq.Count != 0 ? seq.Max(v => v.sref.Height) : 0; var metadata = new LayerMetadata(util, layerHeight, desc); diff --git a/Stack-Solver.csproj b/Stack-Solver.csproj index cb5753b..a0ef0d5 100644 --- a/Stack-Solver.csproj +++ b/Stack-Solver.csproj @@ -1,8 +1,8 @@ - + WinExe - net9.0-windows10.0.26100.1 + net10.0-windows app.manifest Box.ico true @@ -17,6 +17,10 @@ + + + + @@ -25,16 +29,16 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Stack-Solver.slnx b/Stack-Solver.slnx index cfe81c1..117cddb 100644 --- a/Stack-Solver.slnx +++ b/Stack-Solver.slnx @@ -1,4 +1,4 @@ - + diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs new file mode 100644 index 0000000..56d1258 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs @@ -0,0 +1,107 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.Layering; +using Xunit; + + +namespace Stack_Solver.Tests.Strategies +{ + public class BLFGenerationStrategyTests + { + [Fact] + public void Generate_SingleSKU() + { + var skus = new List + { + new() { + SkuId = "A", + Name = "Box A", + Length = 50, + Width = 30, + Height = 20, + Quantity = 500, + Rotatable = true + } + }; + + var pallet = new Pallet("Standard Pallet", 800, 60, 14); + + var strategy = new BLFGenerationStrategy(); + var options = new GenerationOptions { }; + + var layers = strategy.Generate(skus, pallet, options); + + Assert.NotEmpty(layers); + layers.Sort((l1, l2) => l1.Items.Count.CompareTo(l2.Items.Count)); + Assert.Equal(32, layers.Last().Items.Count); + } + + [Fact] + public void Generate_MultipleSKUs() + { + var skus = new List + { + new() { + SkuId = "A", + Name = "Box A", + Length = 21, + Width = 16, + Height = 20, + Quantity = 500, + Rotatable = true + }, + new() { + SkuId = "B", + Name = "Box B", + Length = 52, + Width = 33, + Height = 20, + Quantity = 500, + Rotatable = true + } + }; + + var pallet = new Pallet("Standard Pallet", 120, 100, 14); + + var strategy = new BLFGenerationStrategy(); + var options = new GenerationOptions { }; + + var layers = strategy.Generate(skus, pallet, options); + + Assert.NotEmpty(layers); + layers.Sort((l1, l2) => l1.Items.Count.CompareTo(l2.Items.Count)); + Assert.Equal(33, layers.Last().Items.Count); + layers.Sort((l1, l2) => l1.Metadata.Utilization.CompareTo(l2.Metadata.Utilization)); + Assert.Equal(0.942, layers.Last().Metadata.Utilization, 3); + } + + [Fact] + public void Generate_ShouldRespectSKUQuantityLimits() + { + var skus = new List + { + new SKU + { + SkuId = "X", + Name = "Tiny Box", + Length = 100, + Width = 100, + Height = 50, + Quantity = 2, + Rotatable = true + } + }; + + var pallet = new Pallet("Interesting Pallet", 300, 200, 14); + var strategy = new BLFGenerationStrategy(); + + var layers = strategy.Generate(skus, pallet, new GenerationOptions { }); + var totalPlaced = 0; + + foreach (var layer in layers) + totalPlaced += layer.Items.Count(i => i.SkuType.SkuId == "X"); + + Assert.True(totalPlaced <= 2); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs new file mode 100644 index 0000000..25f1bf2 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs @@ -0,0 +1,182 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.Layering; +using Xunit; + +namespace Stack_Solver.Tests.Strategies +{ + public class HomogeneousGenerationStrategyTests + { + [Fact] + public void Name_IsExpected() + { + var strat = new HomogeneousGenerationStrategy(); + Assert.Equal("Homogeneous Grid", strat.Name); + } + + [Fact] + public void Generate_SingleRotatableSKU_ProducesNormalAndRotatedLayers() + { + var skus = new List + { + new() + { + SkuId = "A", + Name = "Box A", + Length = 40, + Width = 30, + Height = 10, + Quantity = 999, + Rotatable = true + } + }; + + var pallet = new Pallet("Test Pallet", 120, 90, 14); + + var strat = new HomogeneousGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.Equal(2, layers.Count); + + var normal = layers.Single(l => l.Name.EndsWith("_normal")); + var rotated = layers.Single(l => l.Name.EndsWith("_rotated")); + + Assert.Equal(9, normal.Items.Count); + Assert.Equal(1.0, normal.Metadata.Utilization, 5); + Assert.Equal(10, normal.Metadata.Height); + Assert.NotNull(normal.Geometry); + Assert.Equal(90, normal.Geometry!.Width); + Assert.Equal(120, normal.Geometry!.Length); + int normalTrue = 0; + foreach (var v in normal.Geometry!.OccupancyGrid) if (v) normalTrue++; + Assert.Equal(120 * 90, normalTrue); + Assert.All(normal.Items, it => Assert.False(it.Rotated)); + Assert.Contains(normal.Items, it => it.X == 0 && it.Y == 0); + Assert.Contains(normal.Items, it => it.X == 80 && it.Y == 60); + + Assert.Equal(8, rotated.Items.Count); + Assert.Equal(9600.0 / 10800.0, rotated.Metadata.Utilization, 6); + Assert.Equal(10, rotated.Metadata.Height); + Assert.NotNull(rotated.Geometry); + int rotTrue = 0; + foreach (var v in rotated.Geometry!.OccupancyGrid) if (v) rotTrue++; + Assert.Equal(9600, rotTrue); + Assert.All(rotated.Items, it => Assert.True(it.Rotated)); + Assert.Contains(rotated.Items, it => it.X == 0 && it.Y == 0); + Assert.Contains(rotated.Items, it => it.X == 90 && it.Y == 40); + } + + [Fact] + public void Generate_NonRotatableSKU_ProducesSingleLayer() + { + var skus = new List + { + new() + { + SkuId = "B", + Name = "Box B", + Length = 25, + Width = 20, + Height = 7, + Quantity = 999, + Rotatable = false + } + }; + var pallet = new Pallet("Test Pallet", 100, 60, 14); + + var strat = new HomogeneousGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.Single(layers); + var layer = layers.Single(); + Assert.Equal(12, layer.Items.Count); + Assert.All(layer.Items, it => Assert.False(it.Rotated)); + } + + [Fact] + public void Generate_SquareSKU_NoRotatedVariant() + { + var skus = new List + { + new() + { + SkuId = "C", + Name = "Box C", + Length = 30, + Width = 30, + Height = 8, + Quantity = 999, + Rotatable = true + } + }; + var pallet = new Pallet("Test Pallet", 120, 90, 14); + + var strat = new HomogeneousGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.Single(layers); + var layer = layers.Single(); + Assert.Equal(12, layer.Items.Count); + Assert.All(layer.Items, it => Assert.False(it.Rotated)); + } + + [Fact] + public void Generate_SKUTooLarge_NoLayers() + { + var skus = new List + { + new() + { + SkuId = "D", + Name = "Huge", + Length = 200, + Width = 200, + Height = 50, + Rotatable = true + } + }; + var pallet = new Pallet("Small Pallet", 100, 100, 14); + + var strat = new HomogeneousGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.Empty(layers); + } + + [Fact] + public void Generate_MultipleSKUs_ProducesLayersPerSKU() + { + var skus = new List + { + new() + { + SkuId = "A", + Name = "A", + Length = 20, + Width = 10, + Height = 5, + Rotatable = true + }, + new() + { + SkuId = "B", + Name = "B", + Length = 30, + Width = 30, + Height = 10, + Rotatable = true + } + }; + var pallet = new Pallet("Test Pallet", 100, 60, 14); + + var strat = new HomogeneousGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.Equal(3, layers.Count); + + Assert.Contains(layers, l => l.Name.StartsWith("hom_grid_A_normal")); + Assert.Contains(layers, l => l.Name.StartsWith("hom_grid_A_rotated")); + Assert.Contains(layers, l => l.Name.StartsWith("hom_grid_B_normal")); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs new file mode 100644 index 0000000..6dc063a --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs @@ -0,0 +1,165 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.Layering; + +namespace Stack_Solver.Tests.Strategies +{ + public class StripFillGenerationStrategyTests + { + [Fact] + public void Name_IsExpected() + { + var strat = new StripFillGenerationStrategy(); + Assert.Equal("Strip-Fill", strat.Name); + } + + [Fact] + public void Generate_SingleNonRotatableSKU_DeterministicCountsAndUtilization() + { + var skus = new List + { + new() + { + SkuId = "A", + Name = "A", + Length = 40, + Width = 30, + Height = 10, + Quantity = 999, + Rotatable = false + } + }; + + var pallet = new Pallet("Test Pallet", 120, 90, 14); + var strat = new StripFillGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.Equal(3, layers.Count); + var counts = layers.Select(l => l.Items.Count).OrderBy(x => x).ToArray(); + Assert.Equal(new[] { 3, 6, 9 }, counts); + + var best = layers.MaxBy(l => l.Metadata.Utilization)!; + Assert.Equal(9, best.Items.Count); + Assert.Equal(1.0, best.Metadata.Utilization, 6); + Assert.Equal(10, best.Metadata.Height); + + Assert.NotNull(best.Geometry); + Assert.Equal(90, best.Geometry!.Width); + Assert.Equal(120, best.Geometry!.Length); + + var trueCells = 0; + foreach (var v in best.Geometry!.OccupancyGrid) if (v) trueCells++; + var sumAreas = best.Geometry!.ItemRectangles.Sum(r => (int)(r.Width * r.Height)); + Assert.Equal(sumAreas, trueCells); + + Assert.All(best.Items, it => Assert.False(it.Rotated)); + Assert.All(best.Items, it => Assert.InRange(it.X + (it.Rotated ? it.SkuType.Width : it.SkuType.Length), 0, pallet.Length)); + Assert.All(best.Items, it => Assert.InRange(it.Y + (it.Rotated ? it.SkuType.Length : it.SkuType.Width), 0, pallet.Width)); + } + + [Fact] + public void Generate_RotationFlagRespected_WhenRotatableFalse_NoRotatedPlacements() + { + var skus = new List + { + new() + { + SkuId = "B", + Name = "B", + Length = 50, + Width = 30, + Height = 7, + Quantity = 999, + Rotatable = false + } + }; + var pallet = new Pallet("Pallet", 200, 120, 14); + var strat = new StripFillGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.NotEmpty(layers); + Assert.All(layers.SelectMany(l => l.Items), it => Assert.False(it.Rotated)); + } + + [Fact] + public void Generate_MultipleSKUs_UniqueCompositionsAndInBounds() + { + var skus = new List + { + new() + { + SkuId = "A", + Name = "A", + Length = 40, + Width = 30, + Height = 10, + Quantity = 999, + Rotatable = true + }, + new() + { + SkuId = "C", + Name = "C", + Length = 30, + Width = 20, + Height = 8, + Quantity = 999, + Rotatable = true + } + }; + var pallet = new Pallet("Pallet", 180, 100, 14); + var strat = new StripFillGenerationStrategy(); + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + + Assert.NotEmpty(layers); + + var keys = layers.Select(l => string.Join(",", l.Items + .GroupBy(i => i.SkuType.SkuId) + .OrderBy(g => g.Key) + .Select(g => $"{g.Key}:{g.Count()}"))); + Assert.Equal(keys.Count(), keys.Distinct().Count()); + + foreach (var layer in layers) + { + Assert.NotNull(layer.Geometry); + Assert.Equal(pallet.Width, layer.Geometry!.Width); + Assert.Equal(pallet.Length, layer.Geometry!.Length); + + foreach (var it in layer.Items) + { + var xSpan = it.Rotated ? it.SkuType.Width : it.SkuType.Length; + var ySpan = it.Rotated ? it.SkuType.Length : it.SkuType.Width; + Assert.InRange(it.X, 0, pallet.Length - xSpan); + Assert.InRange(it.Y, 0, pallet.Width - ySpan); + } + + var trueCells = 0; + foreach (var v in layer.Geometry!.OccupancyGrid) if (v) trueCells++; + var sumAreas = layer.Geometry!.ItemRectangles.Sum(r => (int)(r.Width * r.Height)); + Assert.Equal(sumAreas, trueCells); + } + } + + [Fact] + public void Generate_SKUTooLarge_NoLayers() + { + var skus = new List + { + new() + { + SkuId = "X", + Name = "Huge", + Length = 200, + Width = 200, + Height = 50, + Rotatable = true + } + }; + var pallet = new Pallet("Small", 100, 100, 14); + var strat = new StripFillGenerationStrategy(); + + var layers = strat.Generate(skus, pallet, new GenerationOptions()); + Assert.Empty(layers); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Stack-Solver.Tests.csproj b/Tests/Stack-Solver.Tests/Stack-Solver.Tests.csproj new file mode 100644 index 0000000..b7b243c --- /dev/null +++ b/Tests/Stack-Solver.Tests/Stack-Solver.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0-windows + true + false + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/Pages/SKULibraryPage.xaml b/Views/Pages/SKULibraryPage.xaml index 8b12634..0f3909d 100644 --- a/Views/Pages/SKULibraryPage.xaml +++ b/Views/Pages/SKULibraryPage.xaml @@ -7,8 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="SKU Library Page" - d:DataContext="{d:DesignInstance local:SKULibraryPage, - IsDesignTimeCreatable=False}" + d:DataContext="{d:DesignInstance local:DashboardPage, IsDesignTimeCreatable=False}" d:DesignHeight="450" d:DesignWidth="800" ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}" @@ -22,8 +21,6 @@ - -