--- phase: 03 plan: 01 title: Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models status: pending wave: 0 depends_on: [] files_modified: - SharepointToolbox/Core/Models/StorageNode.cs - SharepointToolbox/Core/Models/StorageScanOptions.cs - SharepointToolbox/Core/Models/SearchResult.cs - SharepointToolbox/Core/Models/SearchOptions.cs - SharepointToolbox/Core/Models/DuplicateGroup.cs - SharepointToolbox/Core/Models/DuplicateItem.cs - SharepointToolbox/Core/Models/DuplicateScanOptions.cs - SharepointToolbox/Services/IStorageService.cs - SharepointToolbox/Services/ISearchService.cs - SharepointToolbox/Services/IDuplicatesService.cs - SharepointToolbox/Services/Export/StorageCsvExportService.cs - SharepointToolbox/Services/Export/StorageHtmlExportService.cs - SharepointToolbox/Services/Export/SearchCsvExportService.cs - SharepointToolbox/Services/Export/SearchHtmlExportService.cs - SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs - SharepointToolbox.Tests/Services/StorageServiceTests.cs - SharepointToolbox.Tests/Services/SearchServiceTests.cs - SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs - SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs - SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs - SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs - SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs autonomous: true requirements: - STOR-01 - STOR-02 - STOR-03 - STOR-04 - STOR-05 - SRCH-01 - SRCH-02 - SRCH-03 - SRCH-04 - DUPL-01 - DUPL-02 - DUPL-03 must_haves: truths: - "dotnet build produces 0 errors after all 7 models, 3 interfaces, and 5 stub export classes are created" - "All 7 test files exist and are discovered by dotnet test (test count > 0)" - "StorageServiceTests, SearchServiceTests, DuplicatesServiceTests compile but skip (stubs referencing types that exist after this plan)" - "The pure-logic tests in DuplicatesServiceTests (MakeKey composite key) are real [Fact] tests — not skipped — and pass" - "Export service tests compile but fail (types exist as stubs with no real implementation yet) — expected until Plans 03/05" artifacts: - path: "SharepointToolbox/Core/Models/StorageNode.cs" provides: "Tree node model for storage metrics display" - path: "SharepointToolbox/Core/Models/SearchResult.cs" provides: "Flat result record for file search output" - path: "SharepointToolbox/Core/Models/DuplicateGroup.cs" provides: "Group record for duplicate detection output" - path: "SharepointToolbox/Services/IStorageService.cs" provides: "Interface enabling ViewModel mocking for storage" - path: "SharepointToolbox/Services/ISearchService.cs" provides: "Interface enabling ViewModel mocking for search" - path: "SharepointToolbox/Services/IDuplicatesService.cs" provides: "Interface enabling ViewModel mocking for duplicates" key_links: - from: "StorageServiceTests.cs" to: "IStorageService" via: "mock interface" pattern: "IStorageService" - from: "SearchServiceTests.cs" to: "ISearchService" via: "mock interface" pattern: "ISearchService" - from: "DuplicatesServiceTests.cs" to: "MakeKey" via: "static pure function" pattern: "MakeKey" --- # Plan 03-01: Wave 0 — Test Scaffolds, Stub Interfaces, and Core Models ## Goal Create all data models, service interfaces, export service stubs, and test scaffolds needed so every subsequent plan has a working `dotnet test --filter` verify command pointing at a real test class. Interfaces and models define the contracts; implementation plans (03-02 through 03-05) fill them in. One set of pure-logic tests (the `MakeKey` composite key function for duplicate detection) are real `[Fact]` tests that pass immediately since the logic is pure and has no CSOM dependencies. ## Context Phase 2 created `PermissionEntry`, `ScanOptions`, `IPermissionsService`, and test scaffolds in exactly this pattern. Phase 3 follows the same Wave 0 approach: models + interfaces first, implementation in subsequent plans. The test project at `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` already has xUnit 2.9.3 + Moq. The export service stubs must compile (the test files reference them) even though their `BuildCsv`/`BuildHtml` methods return empty strings until implemented. ## Tasks ### Task 1: Create all 7 core models and 3 service interfaces **Files:** - `SharepointToolbox/Core/Models/StorageNode.cs` - `SharepointToolbox/Core/Models/StorageScanOptions.cs` - `SharepointToolbox/Core/Models/SearchResult.cs` - `SharepointToolbox/Core/Models/SearchOptions.cs` - `SharepointToolbox/Core/Models/DuplicateGroup.cs` - `SharepointToolbox/Core/Models/DuplicateItem.cs` - `SharepointToolbox/Core/Models/DuplicateScanOptions.cs` - `SharepointToolbox/Services/IStorageService.cs` - `SharepointToolbox/Services/ISearchService.cs` - `SharepointToolbox/Services/IDuplicatesService.cs` **Action:** Create | Write **Why:** All subsequent plans depend on these contracts. Tests must compile against them. Interfaces enable Moq-based unit tests. ```csharp // SharepointToolbox/Core/Models/StorageNode.cs namespace SharepointToolbox.Core.Models; public class StorageNode { public string Name { get; set; } = string.Empty; public string Url { get; set; } = string.Empty; public string SiteTitle { get; set; } = string.Empty; public string Library { get; set; } = string.Empty; public long TotalSizeBytes { get; set; } public long FileStreamSizeBytes { get; set; } public long VersionSizeBytes => Math.Max(0L, TotalSizeBytes - FileStreamSizeBytes); public long TotalFileCount { get; set; } public DateTime? LastModified { get; set; } public int IndentLevel { get; set; } public List Children { get; set; } = new(); } ``` ```csharp // SharepointToolbox/Core/Models/StorageScanOptions.cs namespace SharepointToolbox.Core.Models; public record StorageScanOptions( bool PerLibrary = true, bool IncludeSubsites = false, int FolderDepth = 0 // 0 = library root only; >0 = recurse N levels ); ``` ```csharp // SharepointToolbox/Core/Models/SearchResult.cs namespace SharepointToolbox.Core.Models; public class SearchResult { public string Title { get; set; } = string.Empty; public string Path { get; set; } = string.Empty; public string FileExtension { get; set; } = string.Empty; public DateTime? Created { get; set; } public DateTime? LastModified { get; set; } public string Author { get; set; } = string.Empty; public string ModifiedBy { get; set; } = string.Empty; public long SizeBytes { get; set; } } ``` ```csharp // SharepointToolbox/Core/Models/SearchOptions.cs namespace SharepointToolbox.Core.Models; public record SearchOptions( string[] Extensions, string? Regex, DateTime? CreatedAfter, DateTime? CreatedBefore, DateTime? ModifiedAfter, DateTime? ModifiedBefore, string? CreatedBy, string? ModifiedBy, string? Library, int MaxResults, string SiteUrl ); ``` ```csharp // SharepointToolbox/Core/Models/DuplicateItem.cs namespace SharepointToolbox.Core.Models; public class DuplicateItem { public string Name { get; set; } = string.Empty; public string Path { get; set; } = string.Empty; public string Library { get; set; } = string.Empty; public long? SizeBytes { get; set; } public DateTime? Created { get; set; } public DateTime? Modified { get; set; } public int? FolderCount { get; set; } public int? FileCount { get; set; } } ``` ```csharp // SharepointToolbox/Core/Models/DuplicateGroup.cs namespace SharepointToolbox.Core.Models; public class DuplicateGroup { public string GroupKey { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public List Items { get; set; } = new(); } ``` ```csharp // SharepointToolbox/Core/Models/DuplicateScanOptions.cs namespace SharepointToolbox.Core.Models; public record DuplicateScanOptions( string Mode = "Files", // "Files" or "Folders" bool MatchSize = true, bool MatchCreated = false, bool MatchModified = false, bool MatchSubfolderCount = false, bool MatchFileCount = false, bool IncludeSubsites = false, string? Library = null ); ``` ```csharp // SharepointToolbox/Services/IStorageService.cs using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface IStorageService { Task> CollectStorageAsync( ClientContext ctx, StorageScanOptions options, IProgress progress, CancellationToken ct); } ``` ```csharp // SharepointToolbox/Services/ISearchService.cs using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface ISearchService { Task> SearchFilesAsync( ClientContext ctx, SearchOptions options, IProgress progress, CancellationToken ct); } ``` ```csharp // SharepointToolbox/Services/IDuplicatesService.cs using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface IDuplicatesService { Task> ScanDuplicatesAsync( ClientContext ctx, DuplicateScanOptions options, IProgress progress, CancellationToken ct); } ``` **Verification:** ```bash dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx ``` Expected: 0 errors ### Task 2: Create 5 export service stubs and 7 test scaffold files **Files:** - `SharepointToolbox/Services/Export/StorageCsvExportService.cs` - `SharepointToolbox/Services/Export/StorageHtmlExportService.cs` - `SharepointToolbox/Services/Export/SearchCsvExportService.cs` - `SharepointToolbox/Services/Export/SearchHtmlExportService.cs` - `SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs` - `SharepointToolbox.Tests/Services/StorageServiceTests.cs` - `SharepointToolbox.Tests/Services/SearchServiceTests.cs` - `SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs` - `SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs` - `SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs` - `SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs` - `SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs` **Action:** Create | Write **Why:** Stubs enable test files to compile. The `MakeKey` helper and `VersionSizeBytes` derived property can be unit tested immediately without any CSOM. Export service tests will fail until plans 03-03 and 03-05 implement the real logic — that is the expected state. ```csharp // SharepointToolbox/Services/Export/StorageCsvExportService.cs using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; public class StorageCsvExportService { public string BuildCsv(IReadOnlyList nodes) => string.Empty; // implemented in Plan 03-03 public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct) { var csv = BuildCsv(nodes); await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct); } } ``` ```csharp // SharepointToolbox/Services/Export/StorageHtmlExportService.cs using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; public class StorageHtmlExportService { public string BuildHtml(IReadOnlyList nodes) => string.Empty; // implemented in Plan 03-03 public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct) { var html = BuildHtml(nodes); await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct); } } ``` ```csharp // SharepointToolbox/Services/Export/SearchCsvExportService.cs using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; public class SearchCsvExportService { public string BuildCsv(IReadOnlyList results) => string.Empty; // implemented in Plan 03-05 public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) { var csv = BuildCsv(results); await System.IO.File.WriteAllTextAsync(filePath, csv, new System.Text.UTF8Encoding(true), ct); } } ``` ```csharp // SharepointToolbox/Services/Export/SearchHtmlExportService.cs using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; public class SearchHtmlExportService { public string BuildHtml(IReadOnlyList results) => string.Empty; // implemented in Plan 03-05 public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) { var html = BuildHtml(results); await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct); } } ``` ```csharp // SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; public class DuplicatesHtmlExportService { public string BuildHtml(IReadOnlyList groups) => string.Empty; // implemented in Plan 03-05 public async Task WriteAsync(IReadOnlyList groups, string filePath, CancellationToken ct) { var html = BuildHtml(groups); await System.IO.File.WriteAllTextAsync(filePath, html, System.Text.Encoding.UTF8, ct); } } ``` Now the test scaffold files. The `DuplicatesServiceTests` includes a real pure-logic test for `MakeKey` — define the helper class inline in the same file so it compiles without depending on `DuplicatesService`: ```csharp // SharepointToolbox.Tests/Services/StorageServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using Xunit; namespace SharepointToolbox.Tests.Services; public class StorageServiceTests { [Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")] public Task CollectStorageAsync_ReturnsLibraryNodes_ForDocumentLibraries() => Task.CompletedTask; [Fact(Skip = "Requires live CSOM context — covered by Plan 03-02 implementation")] public Task CollectStorageAsync_WithFolderDepth1_ReturnsSubfolderNodes() => Task.CompletedTask; [Fact] public void StorageNode_VersionSizeBytes_IsNonNegative() { // VersionSizeBytes = TotalSizeBytes - FileStreamSizeBytes (never negative) var node = new StorageNode { TotalSizeBytes = 1000L, FileStreamSizeBytes = 1200L }; Assert.Equal(0L, node.VersionSizeBytes); // Math.Max(0, -200) = 0 } [Fact] public void StorageNode_VersionSizeBytes_IsCorrectWhenPositive() { var node = new StorageNode { TotalSizeBytes = 5000L, FileStreamSizeBytes = 3000L }; Assert.Equal(2000L, node.VersionSizeBytes); } } ``` ```csharp // SharepointToolbox.Tests/Services/SearchServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using Xunit; namespace SharepointToolbox.Tests.Services; public class SearchServiceTests { [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] public Task SearchFilesAsync_WithExtensionFilter_BuildsCorrectKql() => Task.CompletedTask; [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] public Task SearchFilesAsync_PaginationStopsAt50000() => Task.CompletedTask; [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] public Task SearchFilesAsync_FiltersVersionHistoryPaths() => Task.CompletedTask; } ``` ```csharp // SharepointToolbox.Tests/Services/DuplicatesServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using Xunit; namespace SharepointToolbox.Tests.Services; /// /// Pure-logic tests for the MakeKey composite key function (no CSOM needed). /// Inline helper matches the implementation DuplicatesService will produce in Plan 03-04. /// public class DuplicatesServiceTests { // Inline copy of MakeKey to test logic before Plan 03-04 creates the real class private static string MakeKey(DuplicateItem item, DuplicateScanOptions opts) { var parts = new System.Collections.Generic.List { item.Name.ToLowerInvariant() }; if (opts.MatchSize && item.SizeBytes.HasValue) parts.Add(item.SizeBytes.Value.ToString()); if (opts.MatchCreated && item.Created.HasValue) parts.Add(item.Created.Value.Date.ToString("yyyy-MM-dd")); if (opts.MatchModified && item.Modified.HasValue) parts.Add(item.Modified.Value.Date.ToString("yyyy-MM-dd")); if (opts.MatchSubfolderCount && item.FolderCount.HasValue) parts.Add(item.FolderCount.Value.ToString()); if (opts.MatchFileCount && item.FileCount.HasValue) parts.Add(item.FileCount.Value.ToString()); return string.Join("|", parts); } [Fact] public void MakeKey_NameOnly_ReturnsLowercaseName() { var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1000 }; var opts = new DuplicateScanOptions(MatchSize: false); Assert.Equal("report.docx", MakeKey(item, opts)); } [Fact] public void MakeKey_WithSizeMatch_AppendsSizeToKey() { var item = new DuplicateItem { Name = "Report.docx", SizeBytes = 1024 }; var opts = new DuplicateScanOptions(MatchSize: true); Assert.Equal("report.docx|1024", MakeKey(item, opts)); } [Fact] public void MakeKey_WithCreatedAndModified_AppendsDateStrings() { var item = new DuplicateItem { Name = "file.pdf", SizeBytes = 500, Created = new DateTime(2024, 3, 15), Modified = new DateTime(2024, 6, 1) }; var opts = new DuplicateScanOptions(MatchSize: false, MatchCreated: true, MatchModified: true); Assert.Equal("file.pdf|2024-03-15|2024-06-01", MakeKey(item, opts)); } [Fact] public void MakeKey_SameKeyForSameItems_GroupsCorrectly() { var opts = new DuplicateScanOptions(MatchSize: true); var item1 = new DuplicateItem { Name = "Budget.xlsx", SizeBytes = 2048 }; var item2 = new DuplicateItem { Name = "BUDGET.xlsx", SizeBytes = 2048 }; Assert.Equal(MakeKey(item1, opts), MakeKey(item2, opts)); } [Fact] public void MakeKey_DifferentSize_ProducesDifferentKeys() { var opts = new DuplicateScanOptions(MatchSize: true); var item1 = new DuplicateItem { Name = "file.docx", SizeBytes = 100 }; var item2 = new DuplicateItem { Name = "file.docx", SizeBytes = 200 }; Assert.NotEqual(MakeKey(item1, opts), MakeKey(item2, opts)); } [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] public Task ScanDuplicatesAsync_Files_GroupsByCompositeKey() => Task.CompletedTask; [Fact(Skip = "Requires live CSOM context — covered by Plan 03-04 implementation")] public Task ScanDuplicatesAsync_Folders_UsesCamlFSObjType1() => Task.CompletedTask; } ``` ```csharp // SharepointToolbox.Tests/Services/Export/StorageCsvExportServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services.Export; using Xunit; namespace SharepointToolbox.Tests.Services.Export; public class StorageCsvExportServiceTests { [Fact] public void BuildCsv_WithKnownNodes_ProducesHeaderRow() { var svc = new StorageCsvExportService(); var nodes = new List { new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "MySite", TotalSizeBytes = 1024, FileStreamSizeBytes = 800, TotalFileCount = 5, LastModified = new DateTime(2024, 1, 15) } }; var csv = svc.BuildCsv(nodes); Assert.Contains("Library", csv); Assert.Contains("Site", csv); Assert.Contains("Files", csv); Assert.Contains("Total Size", csv); Assert.Contains("Version Size", csv); Assert.Contains("Last Modified", csv); } [Fact] public void BuildCsv_WithEmptyList_ReturnsHeaderOnly() { var svc = new StorageCsvExportService(); var csv = svc.BuildCsv(new List()); Assert.NotEmpty(csv); // must have at least the header row var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Single(lines); // only header, no data rows } [Fact] public void BuildCsv_NodeValues_AppearInOutput() { var svc = new StorageCsvExportService(); var nodes = new List { new() { Name = "Reports", Library = "Reports", SiteTitle = "ProjectSite", TotalSizeBytes = 2048, FileStreamSizeBytes = 1024, TotalFileCount = 10 } }; var csv = svc.BuildCsv(nodes); Assert.Contains("Reports", csv); Assert.Contains("ProjectSite", csv); Assert.Contains("10", csv); } } ``` ```csharp // SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services.Export; using Xunit; namespace SharepointToolbox.Tests.Services.Export; public class StorageHtmlExportServiceTests { [Fact] public void BuildHtml_WithNodes_ContainsToggleJs() { var svc = new StorageHtmlExportService(); var nodes = new List { new() { Name = "Shared Documents", Library = "Shared Documents", SiteTitle = "Site1", TotalSizeBytes = 5000, FileStreamSizeBytes = 4000, TotalFileCount = 20, Children = new List { new() { Name = "Archive", Library = "Shared Documents", SiteTitle = "Site1", TotalSizeBytes = 1000, FileStreamSizeBytes = 800, TotalFileCount = 5 } } } }; var html = svc.BuildHtml(nodes); Assert.Contains("toggle(", html); Assert.Contains("", html); Assert.Contains("Shared Documents", html); } [Fact] public void BuildHtml_WithEmptyList_ReturnsValidHtml() { var svc = new StorageHtmlExportService(); var html = svc.BuildHtml(new List()); Assert.Contains("", html); Assert.Contains(" { new() { Name = "Documents", Library = "Documents", SiteTitle = "Site1", TotalSizeBytes = 1000 }, new() { Name = "Images", Library = "Images", SiteTitle = "Site1", TotalSizeBytes = 2000 } }; var html = svc.BuildHtml(nodes); Assert.Contains("Documents", html); Assert.Contains("Images", html); } } ``` ```csharp // SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services.Export; using Xunit; namespace SharepointToolbox.Tests.Services.Export; public class SearchExportServiceTests { private static SearchResult MakeSample() => new() { Title = "Q1 Budget.xlsx", Path = "https://contoso.sharepoint.com/sites/Finance/Shared Documents/Q1 Budget.xlsx", FileExtension = "xlsx", Created = new DateTime(2024, 1, 10), LastModified = new DateTime(2024, 3, 20), Author = "Alice Smith", ModifiedBy = "Bob Jones", SizeBytes = 48_000 }; // ── CSV tests ────────────────────────────────────────────────────────────── [Fact] public void BuildCsv_WithKnownResults_ContainsExpectedHeader() { var svc = new SearchCsvExportService(); var csv = svc.BuildCsv(new List { MakeSample() }); Assert.Contains("File Name", csv); Assert.Contains("Extension", csv); Assert.Contains("Created", csv); Assert.Contains("Created By", csv); Assert.Contains("Modified By", csv); Assert.Contains("Size", csv); } [Fact] public void BuildCsv_WithEmptyList_ReturnsHeaderOnly() { var svc = new SearchCsvExportService(); var csv = svc.BuildCsv(new List()); Assert.NotEmpty(csv); var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.Single(lines); } [Fact] public void BuildCsv_ResultValues_AppearInOutput() { var svc = new SearchCsvExportService(); var csv = svc.BuildCsv(new List { MakeSample() }); Assert.Contains("Alice Smith", csv); Assert.Contains("xlsx", csv); } // ── HTML tests ───────────────────────────────────────────────────────────── [Fact] public void BuildHtml_WithResults_ContainsSortableColumnScript() { var svc = new SearchHtmlExportService(); var html = svc.BuildHtml(new List { MakeSample() }); Assert.Contains("", html); Assert.Contains("sort", html); // sortable columns JS Assert.Contains("Q1 Budget.xlsx", html); } [Fact] public void BuildHtml_WithResults_ContainsFilterInput() { var svc = new SearchHtmlExportService(); var html = svc.BuildHtml(new List { MakeSample() }); Assert.Contains("filter", html); // filter input element } [Fact] public void BuildHtml_WithEmptyList_ReturnsValidHtml() { var svc = new SearchHtmlExportService(); var html = svc.BuildHtml(new List()); Assert.Contains("", html); } } ``` ```csharp // SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs using SharepointToolbox.Core.Models; using SharepointToolbox.Services.Export; using Xunit; namespace SharepointToolbox.Tests.Services.Export; public class DuplicatesHtmlExportServiceTests { private static DuplicateGroup MakeGroup(string name, int count) => new() { GroupKey = $"{name}|1024", Name = name, Items = Enumerable.Range(1, count).Select(i => new DuplicateItem { Name = name, Path = $"https://contoso.sharepoint.com/sites/Site{i}/{name}", Library = "Shared Documents", SizeBytes = 1024 }).ToList() }; [Fact] public void BuildHtml_WithGroups_ContainsGroupCards() { var svc = new DuplicatesHtmlExportService(); var groups = new List { MakeGroup("report.docx", 3) }; var html = svc.BuildHtml(groups); Assert.Contains("", html); Assert.Contains("report.docx", html); } [Fact] public void BuildHtml_WithMultipleGroups_AllGroupNamesPresent() { var svc = new DuplicatesHtmlExportService(); var groups = new List { MakeGroup("budget.xlsx", 2), MakeGroup("photo.jpg", 4) }; var html = svc.BuildHtml(groups); Assert.Contains("budget.xlsx", html); Assert.Contains("photo.jpg", html); } [Fact] public void BuildHtml_WithEmptyList_ReturnsValidHtml() { var svc = new DuplicatesHtmlExportService(); var html = svc.BuildHtml(new List()); Assert.Contains("", html); } } ``` **Verification:** ```bash dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests" -x ``` Expected: 5 real tests pass (MakeKey logic tests), CSOM stubs skip ## Verification ```bash dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~DuplicatesServiceTests|FullyQualifiedName~StorageServiceTests" -x ``` Expected: 0 build errors; 7 DuplicatesServiceTests+StorageServiceTests pass or skip with no CS errors > **Note on unfiltered test run at Wave 0:** Running `dotnet test` without a filter at this stage will show approximately 15 failing tests across `StorageCsvExportServiceTests`, `StorageHtmlExportServiceTests`, `SearchExportServiceTests`, and `DuplicatesHtmlExportServiceTests`. This is expected — all 5 export service stubs return `string.Empty` until Plans 03-03 and 03-05 implement the real logic. Do not treat these failures as a blocker for Wave 0 completion. ## Commit Message feat(03-01): create Phase 3 models, interfaces, export stubs, and test scaffolds ## Output After completion, create `.planning/phases/03-storage/03-01-SUMMARY.md`