diff --git a/src/ShedBuilder.Api/Controllers/DesignsController.cs b/src/ShedBuilder.Api/Controllers/DesignsController.cs index 7e1bed7..2444ee5 100644 --- a/src/ShedBuilder.Api/Controllers/DesignsController.cs +++ b/src/ShedBuilder.Api/Controllers/DesignsController.cs @@ -96,6 +96,7 @@ public async Task> Create(CreateDesignRequest reque HeightInches = request.HeightInches, RoofPitch = request.RoofPitch, RoofType = request.RoofType, + RoofOverhangInches = request.RoofOverhangInches, Openings = request.Openings?.Select(o => new Opening { Type = o.Type, @@ -136,6 +137,7 @@ public async Task> Update(Guid id, UpdateDesignRequ if (request.HeightInches.HasValue) design.HeightInches = request.HeightInches.Value; if (request.RoofPitch.HasValue) design.RoofPitch = request.RoofPitch.Value; if (request.RoofType.HasValue) design.RoofType = request.RoofType.Value; + if (request.RoofOverhangInches.HasValue) design.RoofOverhangInches = request.RoofOverhangInches.Value; if (request.Openings != null) { design.Openings = request.Openings.Select(o => new Opening @@ -301,6 +303,7 @@ public async Task> CreateVersion(Guid id, CreateVe HeightInches = design.HeightInches, RoofPitch = design.RoofPitch, RoofType = design.RoofType, + RoofOverhangInches = design.RoofOverhangInches, Openings = design.Openings.Select(o => new Opening { Type = o.Type, @@ -353,6 +356,7 @@ public async Task> RestoreVersion(Guid id, Guid vid design.HeightInches = version.HeightInches; design.RoofPitch = version.RoofPitch; design.RoofType = version.RoofType; + design.RoofOverhangInches = version.RoofOverhangInches; design.Openings = version.Openings.Select(o => new Opening { Type = o.Type, @@ -380,6 +384,7 @@ public async Task> RestoreVersion(Guid id, Guid vid HeightInches = d.HeightInches, RoofPitch = d.RoofPitch, RoofType = d.RoofType, + RoofOverhangInches = d.RoofOverhangInches, Openings = d.Openings.Select(o => new OpeningDto { Type = o.Type, @@ -464,6 +469,7 @@ public async Task> RestoreVersion(Guid id, Guid vid HeightInches = v.HeightInches, RoofPitch = v.RoofPitch, RoofType = v.RoofType, + RoofOverhangInches = v.RoofOverhangInches, Openings = v.Openings.Select(o => new OpeningDto { Type = o.Type, diff --git a/src/ShedBuilder.Api/Models/Design.cs b/src/ShedBuilder.Api/Models/Design.cs index 2c1da01..c1d6c07 100644 --- a/src/ShedBuilder.Api/Models/Design.cs +++ b/src/ShedBuilder.Api/Models/Design.cs @@ -45,6 +45,9 @@ public class Design [Column("roof_type")] public RoofType RoofType { get; set; } + [Column("roof_overhang_inches")] + public int RoofOverhangInches { get; set; } = 12; + [Column("user_id")] public Guid UserId { get; set; } diff --git a/src/ShedBuilder.Api/Models/DesignVersion.cs b/src/ShedBuilder.Api/Models/DesignVersion.cs index 971b960..9c5183a 100644 --- a/src/ShedBuilder.Api/Models/DesignVersion.cs +++ b/src/ShedBuilder.Api/Models/DesignVersion.cs @@ -47,6 +47,9 @@ public class DesignVersion [Column("roof_type")] public RoofType RoofType { get; set; } + [Column("roof_overhang_inches")] + public int RoofOverhangInches { get; set; } = 12; + [Column("openings", TypeName = "jsonb")] public List Openings { get; set; } = new(); diff --git a/src/ShedBuilder.Api/Models/Dtos.cs b/src/ShedBuilder.Api/Models/Dtos.cs index daebcfa..b478405 100644 --- a/src/ShedBuilder.Api/Models/Dtos.cs +++ b/src/ShedBuilder.Api/Models/Dtos.cs @@ -31,6 +31,9 @@ public record CreateDesignRequest : IValidatableObject public RoofType RoofType { get; init; } = RoofType.Gable; + [Range(0, 36)] + public int RoofOverhangInches { get; init; } = 12; + public List? Openings { get; init; } public IEnumerable Validate(ValidationContext validationContext) @@ -68,6 +71,9 @@ public record UpdateDesignRequest : IValidatableObject public RoofType? RoofType { get; init; } + [Range(0, 36)] + public int? RoofOverhangInches { get; init; } + public List? Openings { get; init; } public IEnumerable Validate(ValidationContext validationContext) @@ -89,6 +95,7 @@ public record DesignResponse public int HeightInches { get; init; } public decimal RoofPitch { get; init; } public RoofType RoofType { get; init; } + public int RoofOverhangInches { get; init; } public List Openings { get; init; } = new(); public DateTime CreatedAt { get; init; } public DateTime UpdatedAt { get; init; } @@ -170,6 +177,7 @@ public record VersionResponse public int HeightInches { get; init; } public decimal RoofPitch { get; init; } public RoofType RoofType { get; init; } + public int RoofOverhangInches { get; init; } public List Openings { get; init; } = new(); public DateTime CreatedAt { get; init; } } diff --git a/src/ShedBuilder.Api/Services/BomCalculator.cs b/src/ShedBuilder.Api/Services/BomCalculator.cs index d79144e..7313d5d 100644 --- a/src/ShedBuilder.Api/Services/BomCalculator.cs +++ b/src/ShedBuilder.Api/Services/BomCalculator.cs @@ -26,7 +26,7 @@ public BomResponse Calculate(Design design) items.AddRange(CalculateFloor(widthInches, depthInches)); items.AddRange(CalculateWalls(widthInches, depthInches, heightInches, design.Openings)); - items.AddRange(CalculateRoof(widthInches, depthInches, roofPitch, design.RoofType)); + items.AddRange(CalculateRoof(widthInches, depthInches, roofPitch, design.RoofType, (double)design.RoofOverhangInches)); items.AddRange(CalculateHardware(widthInches, depthInches, heightInches, design.RoofType)); items.AddRange(CalculateOpenings(design.Openings)); @@ -131,19 +131,22 @@ private List CalculateWalls(double widthIn, double depthIn, double heig return items; } - private List CalculateRoof(double widthIn, double depthIn, double roofPitch, RoofType roofType) + private List CalculateRoof(double widthIn, double depthIn, double roofPitch, RoofType roofType, double overhangIn) { var items = new List(); if (roofType == RoofType.Gable) { - // Gable roof: ridge runs along depth, rafters span half the width + // Gable roof: ridge runs along depth, rafters span half the width + overhang var halfSpan = widthIn / 2.0; var rise = halfSpan * roofPitch / 12.0; - var rafterLength = Math.Sqrt(halfSpan * halfSpan + rise * rise); + var rafterSpan = halfSpan + overhangIn; + var rafterRise = rafterSpan * roofPitch / 12.0; + var rafterLength = Math.Sqrt(rafterSpan * rafterSpan + rafterRise * rafterRise); // Rafters at 16" OC on each side - var raftersPerSide = (int)Math.Ceiling(depthIn / 16.0) + 1; + var roofDepth = depthIn + 2 * overhangIn; + var raftersPerSide = (int)Math.Ceiling(roofDepth / 16.0) + 1; items.Add(new BomItem { Material = "Framing lumber", @@ -157,14 +160,14 @@ private List CalculateRoof(double widthIn, double depthIn, double roofP items.Add(new BomItem { Material = "Framing lumber", - Dimensions = $"2×8 × {LengthLabel(depthIn)}", + Dimensions = $"2×8 × {LengthLabel(roofDepth)}", Quantity = 1, Unit = "pieces", Category = "Roof" }); // Roof sheathing (both sides) - var roofSqFtPerSide = (rafterLength * depthIn) / 144.0; + var roofSqFtPerSide = (rafterLength * roofDepth) / 144.0; var roofSheets = (int)Math.Ceiling(roofSqFtPerSide * 2 / 32.0); items.Add(new BomItem { @@ -189,11 +192,13 @@ private List CalculateRoof(double widthIn, double depthIn, double roofP } else // LeanTo { - // Lean-to: single slope from high side to low side across width - var rise = widthIn * roofPitch / 12.0; - var rafterLength = Math.Sqrt(widthIn * widthIn + rise * rise); + // Lean-to: single slope from high side to low side across width + overhang + var rafterSpan = widthIn + overhangIn; + var rafterRise = rafterSpan * roofPitch / 12.0; + var rafterLength = Math.Sqrt(rafterSpan * rafterSpan + rafterRise * rafterRise); - var rafterCount = (int)Math.Ceiling(depthIn / 16.0) + 1; + var roofDepth = depthIn + 2 * overhangIn; + var rafterCount = (int)Math.Ceiling(roofDepth / 16.0) + 1; items.Add(new BomItem { Material = "Framing lumber", @@ -204,7 +209,7 @@ private List CalculateRoof(double widthIn, double depthIn, double roofP }); // Roof sheathing - var roofSqFt = (rafterLength * depthIn) / 144.0; + var roofSqFt = (rafterLength * roofDepth) / 144.0; var roofSheets = (int)Math.Ceiling(roofSqFt / 32.0); items.Add(new BomItem { diff --git a/src/ShedBuilder.Api/Services/PdfExporter.cs b/src/ShedBuilder.Api/Services/PdfExporter.cs index 278339d..697868a 100644 --- a/src/ShedBuilder.Api/Services/PdfExporter.cs +++ b/src/ShedBuilder.Api/Services/PdfExporter.cs @@ -52,6 +52,8 @@ public byte[] Export(Design design, CostResponse costResponse) table.Cell().Text($"{design.RoofPitch}/12"); table.Cell().Text("Roof Type:").SemiBold(); table.Cell().Text(design.RoofType.ToString()); + table.Cell().Text("Roof Overhang:").SemiBold(); + table.Cell().Text($"{design.RoofOverhangInches}\""); }); // Openings diff --git a/src/ShedBuilder.Api/Services/StlExporter.cs b/src/ShedBuilder.Api/Services/StlExporter.cs index 737b705..509c875 100644 --- a/src/ShedBuilder.Api/Services/StlExporter.cs +++ b/src/ShedBuilder.Api/Services/StlExporter.cs @@ -56,30 +56,32 @@ public byte[] Export(Design design) new Vec3(1, 0, 0))); // Roof + var oh = (float)design.RoofOverhangInches; + if (design.RoofType == RoofType.Gable) { var rise = width / 2f * pitch / 12f; var ridgeY = height + rise; var ridgeX = width / 2f; - // Left roof slope + // Left roof slope (extended by overhang on all edges) triangles.AddRange(MakeQuad( - new Vec3(0, height, 0), new Vec3(ridgeX, ridgeY, 0), - new Vec3(ridgeX, ridgeY, depth), new Vec3(0, height, depth), - ComputeNormal(new Vec3(0, height, 0), new Vec3(ridgeX, ridgeY, 0), new Vec3(ridgeX, ridgeY, depth)))); + new Vec3(-oh, height, -oh), new Vec3(ridgeX, ridgeY, -oh), + new Vec3(ridgeX, ridgeY, depth + oh), new Vec3(-oh, height, depth + oh), + ComputeNormal(new Vec3(-oh, height, 0), new Vec3(ridgeX, ridgeY, 0), new Vec3(ridgeX, ridgeY, depth)))); - // Right roof slope + // Right roof slope (extended by overhang on all edges) triangles.AddRange(MakeQuad( - new Vec3(ridgeX, ridgeY, 0), new Vec3(width, height, 0), - new Vec3(width, height, depth), new Vec3(ridgeX, ridgeY, depth), - ComputeNormal(new Vec3(ridgeX, ridgeY, 0), new Vec3(width, height, 0), new Vec3(width, height, depth)))); + new Vec3(ridgeX, ridgeY, -oh), new Vec3(width + oh, height, -oh), + new Vec3(width + oh, height, depth + oh), new Vec3(ridgeX, ridgeY, depth + oh), + ComputeNormal(new Vec3(ridgeX, ridgeY, 0), new Vec3(width + oh, height, 0), new Vec3(width + oh, height, depth)))); - // Front gable triangle + // Front gable triangle (stays at wall face) triangles.Add(new Triangle( ComputeNormal(new Vec3(0, height, 0), new Vec3(width, height, 0), new Vec3(ridgeX, ridgeY, 0)), new Vec3(0, height, 0), new Vec3(width, height, 0), new Vec3(ridgeX, ridgeY, 0))); - // Back gable triangle + // Back gable triangle (stays at wall face) triangles.Add(new Triangle( ComputeNormal(new Vec3(width, height, depth), new Vec3(0, height, depth), new Vec3(ridgeX, ridgeY, depth)), new Vec3(width, height, depth), new Vec3(0, height, depth), new Vec3(ridgeX, ridgeY, depth))); @@ -89,18 +91,18 @@ public byte[] Export(Design design) var rise = width * pitch / 12f; var highY = height + rise; - // Single slope from left (high) to right (low at wall height) + // Single slope (extended by overhang) triangles.AddRange(MakeQuad( - new Vec3(0, highY, 0), new Vec3(width, height, 0), - new Vec3(width, height, depth), new Vec3(0, highY, depth), - ComputeNormal(new Vec3(0, highY, 0), new Vec3(width, height, 0), new Vec3(width, height, depth)))); + new Vec3(-oh, highY, -oh), new Vec3(width + oh, height, -oh), + new Vec3(width + oh, height, depth + oh), new Vec3(-oh, highY, depth + oh), + ComputeNormal(new Vec3(-oh, highY, 0), new Vec3(width + oh, height, 0), new Vec3(width + oh, height, depth)))); // Front triangle fill triangles.Add(new Triangle( new Vec3(0, 0, -1), new Vec3(0, height, 0), new Vec3(0, highY, 0), new Vec3(width, height, 0))); - // Back triangle fill (note: this is a simplification) + // Back triangle fill triangles.Add(new Triangle( new Vec3(0, 0, 1), new Vec3(width, height, depth), new Vec3(0, highY, depth), new Vec3(0, height, depth))); diff --git a/src/shed-builder-ui/src/components/DesignPanel.tsx b/src/shed-builder-ui/src/components/DesignPanel.tsx index fd6b9dd..fa22954 100644 --- a/src/shed-builder-ui/src/components/DesignPanel.tsx +++ b/src/shed-builder-ui/src/components/DesignPanel.tsx @@ -126,6 +126,17 @@ export default memo(function DesignPanel({ design, onChange, saveStatus }: Props + onChange({ roofOverhangInches: Number(e.target.value) })} + inputProps={{ min: 0, max: 36, step: 1 }} + sx={{ mb: 2 }} + /> + diff --git a/src/shed-builder-ui/src/components/ShedViewer3D.tsx b/src/shed-builder-ui/src/components/ShedViewer3D.tsx index 7f0f866..2544fbd 100644 --- a/src/shed-builder-ui/src/components/ShedViewer3D.tsx +++ b/src/shed-builder-ui/src/components/ShedViewer3D.tsx @@ -225,31 +225,33 @@ function ShedGeometry({ design }: Props) { {/* Roof */} {design.roofType === 'Gable' ? ( - + ) : ( - + )} ); } -function GableRoof({ w, d, h, pitch }: { w: number; d: number; h: number; pitch: number }) { +function GableRoof({ w, d, h, pitch, oh }: { w: number; d: number; h: number; pitch: number; oh: number }) { const halfW = w / 2; const rise = (halfW * pitch) / 12; - const rafterLen = Math.sqrt(halfW * halfW + rise * rise); + const rafterSpan = halfW + oh; + const rafterRise = (rafterSpan * pitch) / 12; + const rafterLen = Math.sqrt(rafterSpan * rafterSpan + rafterRise * rafterRise); const angle = Math.atan2(rise, halfW); return ( {/* Left slope */} - - + + {/* Right slope */} - - + + @@ -277,15 +279,16 @@ function GableTriangle({ w, rise }: { w: number; rise: number }) { return ; } -function LeanToRoof({ w, d, h, pitch }: { w: number; d: number; h: number; pitch: number }) { +function LeanToRoof({ w, d, h, pitch, oh }: { w: number; d: number; h: number; pitch: number; oh: number }) { const rise = (w * pitch) / 12; - const rafterLen = Math.sqrt(w * w + rise * rise); + const rafterSpan = w + oh; + const rafterLen = Math.sqrt(rafterSpan * rafterSpan + ((rafterSpan * pitch) / 12) ** 2); const angle = Math.atan2(rise, w); return ( - + diff --git a/src/shed-builder-ui/src/components/__tests__/DesignPanel.test.tsx b/src/shed-builder-ui/src/components/__tests__/DesignPanel.test.tsx index dee045d..10fb98f 100644 --- a/src/shed-builder-ui/src/components/__tests__/DesignPanel.test.tsx +++ b/src/shed-builder-ui/src/components/__tests__/DesignPanel.test.tsx @@ -15,6 +15,7 @@ const mockDesign: Design = { heightInches: 0, roofPitch: 4, roofType: 'Gable', + roofOverhangInches: 12, openings: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', @@ -115,6 +116,17 @@ describe('DesignPanel', () => { expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ roofPitch: expect.any(Number) })); }); + it('calls onChange when roof overhang is changed', async () => { + const onChange = vi.fn(); + render(); + + const overhangInput = screen.getByRole('spinbutton', { name: /roof overhang/i }); + await userEvent.clear(overhangInput); + await userEvent.type(overhangInput, '6'); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ roofOverhangInches: expect.any(Number) })); + }); + it('renders with LeanTo roof design', () => { const leanToDesign = { ...mockDesign, roofType: 'LeanTo' as const }; render(); diff --git a/src/shed-builder-ui/src/types.ts b/src/shed-builder-ui/src/types.ts index fcb81bb..379c031 100644 --- a/src/shed-builder-ui/src/types.ts +++ b/src/shed-builder-ui/src/types.ts @@ -22,6 +22,7 @@ export interface Design { heightInches: number; roofPitch: number; roofType: RoofType; + roofOverhangInches: number; openings: Opening[]; createdAt: string; updatedAt: string; @@ -37,6 +38,7 @@ export interface CreateDesignRequest { heightInches?: number; roofPitch?: number; roofType?: RoofType; + roofOverhangInches?: number; openings?: Opening[]; } @@ -50,6 +52,7 @@ export interface UpdateDesignRequest { heightInches?: number; roofPitch?: number; roofType?: RoofType; + roofOverhangInches?: number; openings?: Opening[]; } @@ -95,6 +98,7 @@ export interface DesignVersion { heightInches: number; roofPitch: number; roofType: RoofType; + roofOverhangInches: number; openings: Opening[]; createdAt: string; } diff --git a/tests/ShedBuilder.Api.Tests/Integration/ApiIntegrationTests.cs b/tests/ShedBuilder.Api.Tests/Integration/ApiIntegrationTests.cs index e419c2f..907d040 100644 --- a/tests/ShedBuilder.Api.Tests/Integration/ApiIntegrationTests.cs +++ b/tests/ShedBuilder.Api.Tests/Integration/ApiIntegrationTests.cs @@ -357,6 +357,78 @@ public async Task Versions_CreateListRestore() Assert.Equal(8, restored!.WidthFeet); } + [Fact] + public async Task CreateAndGet_RoofOverhangInches_RoundTrip() + { + await CreateAuthenticatedUser("Overhang", $"overhang-{Guid.NewGuid()}@test.com"); + + var create = new CreateDesignRequest + { + Name = "Overhang Shed", + WidthFeet = 10, + DepthFeet = 12, + HeightFeet = 8, + RoofPitch = 4, + RoofType = RoofType.Gable, + RoofOverhangInches = 18, + }; + + var createResponse = await _client.PostAsJsonAsync("/api/v1/designs", create, JsonOptions); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var design = await ReadJson(createResponse.Content); + Assert.Equal(18, design!.RoofOverhangInches); + + var getResponse = await _client.GetAsync($"/api/v1/designs/{design.Id}"); + var fetched = await ReadJson(getResponse.Content); + Assert.Equal(18, fetched!.RoofOverhangInches); + } + + [Fact] + public async Task Update_RoofOverhangInches_ReturnsUpdated() + { + await CreateAuthenticatedUser("OhUpdate", $"ohupdate-{Guid.NewGuid()}@test.com"); + + var create = new CreateDesignRequest { Name = "OH Update Test" }; + var createResponse = await _client.PostAsJsonAsync("/api/v1/designs", create, JsonOptions); + var design = await ReadJson(createResponse.Content); + Assert.Equal(12, design!.RoofOverhangInches); // default + + var update = new UpdateDesignRequest { RoofOverhangInches = 6 }; + var updateResponse = await _client.PutAsJsonAsync($"/api/v1/designs/{design.Id}", update, JsonOptions); + var updated = await ReadJson(updateResponse.Content); + Assert.Equal(6, updated!.RoofOverhangInches); + } + + [Fact] + public async Task VersionRestore_PreservesRoofOverhangInches() + { + await CreateAuthenticatedUser("OhVersion", $"ohversion-{Guid.NewGuid()}@test.com"); + + var create = new CreateDesignRequest { Name = "OH Version Test", RoofOverhangInches = 24 }; + var createResponse = await _client.PostAsJsonAsync("/api/v1/designs", create, JsonOptions); + var design = await ReadJson(createResponse.Content); + var id = design!.Id; + + // Save version with overhang=24 + await _client.PostAsJsonAsync($"/api/v1/designs/{id}/versions", + new CreateVersionRequest { Label = "With Overhang" }, JsonOptions); + + // Change overhang + await _client.PutAsJsonAsync($"/api/v1/designs/{id}", + new UpdateDesignRequest { RoofOverhangInches = 6 }, JsonOptions); + + // Restore + var versions = await _client.GetAsync($"/api/v1/designs/{id}/versions"); + var versionPage = await ReadJson>(versions.Content); + Assert.Equal(24, versionPage!.Items[0].RoofOverhangInches); + + var restoreResponse = await _client.PostAsync( + $"/api/v1/designs/{id}/versions/{versionPage.Items[0].Id}/restore", null); + var restored = await ReadJson(restoreResponse.Content); + Assert.Equal(24, restored!.RoofOverhangInches); + } + [Fact] public async Task GetNonExistentDesign_Returns404() { diff --git a/tests/ShedBuilder.Api.Tests/Services/BomCalculatorTests.cs b/tests/ShedBuilder.Api.Tests/Services/BomCalculatorTests.cs index 6b61519..0fd0014 100644 --- a/tests/ShedBuilder.Api.Tests/Services/BomCalculatorTests.cs +++ b/tests/ShedBuilder.Api.Tests/Services/BomCalculatorTests.cs @@ -338,6 +338,52 @@ public void Calculate_WithOpenings_ReducesOsbSheathing() Assert.True(withOpeningsOsb <= noOpeningsOsb); } + [Fact] + public void Calculate_Gable_OverhangIncreasesRafterLength() + { + var noOverhang = CreateDesign(); + noOverhang.RoofOverhangInches = 0; + var withOverhang = CreateDesign(); + withOverhang.RoofOverhangInches = 24; + + var noOhResult = _calculator.Calculate(noOverhang); + var ohResult = _calculator.Calculate(withOverhang); + + var noOhRafter = noOhResult.Items.First(i => i.Category == "Roof" && i.Dimensions.Contains("2×6")); + var ohRafter = ohResult.Items.First(i => i.Category == "Roof" && i.Dimensions.Contains("2×6")); + Assert.NotEqual(noOhRafter.Dimensions, ohRafter.Dimensions); + } + + [Fact] + public void Calculate_LeanTo_OverhangIncreasesSheathing() + { + var noOverhang = CreateDesign(roofType: RoofType.LeanTo); + noOverhang.RoofOverhangInches = 0; + var withOverhang = CreateDesign(roofType: RoofType.LeanTo); + withOverhang.RoofOverhangInches = 24; + + var noOhResult = _calculator.Calculate(noOverhang); + var ohResult = _calculator.Calculate(withOverhang); + + var noOhSheathing = noOhResult.Items.First(i => i.Category == "Roof" && i.Material == "Plywood sheathing"); + var ohSheathing = ohResult.Items.First(i => i.Category == "Roof" && i.Material == "Plywood sheathing"); + Assert.True(ohSheathing.Quantity >= noOhSheathing.Quantity); + } + + [Fact] + public void Calculate_ZeroOverhang_ProducesValidResults() + { + var design = CreateDesign(); + design.RoofOverhangInches = 0; + var result = _calculator.Calculate(design); + + Assert.Contains(result.Items, i => i.Category == "Roof"); + foreach (var item in result.Items) + { + Assert.True(item.Quantity > 0, $"{item.Material} has quantity {item.Quantity}"); + } + } + [Fact] public void Calculate_SmallShed_AllQuantitiesPositive() {