microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/context-working

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/tests/linting/FrontmatterValidation.Tests.ps1

1776lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4<#
5.SYNOPSIS
6 Unit tests for FrontmatterValidation.psm1 module.
7.DESCRIPTION
8 Tests pure validation functions extracted for testability.
9 Covers ValidationIssue class, shared helpers, and content-type validators.
10#>
11
12# Use 'using module' to access class types (must be before any other code)
13using module ..\..\linting\Modules\FrontmatterValidation.psm1
14
15BeforeAll {
16 # Import the module under test
17 $script:ModulePath = Join-Path $PSScriptRoot '..\..\linting\Modules\FrontmatterValidation.psm1'
18 Import-Module $script:ModulePath -Force
19 Import-Module (Join-Path $PSScriptRoot '..\..\lib\Modules\CIHelpers.psm1') -Force
20
21 # Get module reference for class instantiation in module scope
22 # This avoids parse-time caching issues with 'using module'
23 $script:FVModule = Get-Module FrontmatterValidation
24
25 # Helper functions for new classes (instantiate in module scope)
26 function script:New-FileValidationResult {
27 param([string]$FilePath)
28 & $script:FVModule { param($fp) [FileValidationResult]::new($fp) } $FilePath
29 }
30
31 function script:New-ValidationSummary {
32 & $script:FVModule { [ValidationSummary]::new() }
33 }
34
35 function script:New-ValidationIssue {
36 param(
37 [string]$Type = 'Warning',
38 [string]$Field = '',
39 [string]$Message = 'Test message',
40 [string]$FilePath = 'test.md'
41 )
42 & $script:FVModule {
43 param($t, $f, $m, $fp)
44 [ValidationIssue]::new($t, $f, $m, $fp)
45 } $Type $Field $Message $FilePath
46 }
47
48 function script:New-ValidationIssueEmpty {
49 & $script:FVModule { [ValidationIssue]::new() }
50 }
51
52 function script:New-FileTypeInfo {
53 param([hashtable]$Properties)
54 & $script:FVModule {
55 param($props)
56 $info = [FileTypeInfo]::new()
57 foreach ($key in $props.Keys) {
58 $info.$key = $props[$key]
59 }
60 $info
61 } $Properties
62 }
63}
64
65AfterAll {
66 Remove-Module FrontmatterValidation -ErrorAction SilentlyContinue
67}
68
69#region ValidationIssue Class Tests
70
71Describe 'ValidationIssue Class' -Tag 'Unit' {
72 Context 'Constructor with all parameters' {
73 It 'Creates instance with Type, Field, Message, FilePath' {
74 $issue = [ValidationIssue]::new('Error', 'title', 'Missing required field', 'docs/test.md')
75
76 $issue.Type | Should -Be 'Error'
77 $issue.Field | Should -Be 'title'
78 $issue.Message | Should -Be 'Missing required field'
79 $issue.FilePath | Should -Be 'docs/test.md'
80 }
81
82 It 'Accepts Warning type' {
83 $issue = [ValidationIssue]::new('Warning', 'ms.date', 'Invalid format', 'README.md')
84
85 $issue.Type | Should -Be 'Warning'
86 }
87
88 It 'Accepts Notice type' {
89 $issue = [ValidationIssue]::new('Notice', 'author', 'Optional field missing', 'file.md')
90
91 $issue.Type | Should -Be 'Notice'
92 }
93 }
94
95 Context 'Constructor requires FilePath' {
96 It 'FilePath is required - empty string allowed' {
97 # ValidationIssue requires 4 parameters; FilePath can be empty
98 $issue = [ValidationIssue]::new('Error', 'description', 'Cannot be empty', '')
99
100 $issue.Type | Should -Be 'Error'
101 $issue.Field | Should -Be 'description'
102 $issue.Message | Should -Be 'Cannot be empty'
103 $issue.FilePath | Should -Be ''
104 }
105 }
106
107 Context 'Default constructor' {
108 It 'Creates instance with defaults using parameterless constructor' {
109 $issue = New-ValidationIssueEmpty
110
111 $issue.Line | Should -Be 0
112 $issue.Type | Should -Be 'Warning'
113 }
114 }
115}
116
117#endregion
118
119#region FileValidationResult Class Tests
120
121Describe 'FileValidationResult Class' -Tag 'Unit' {
122 Context 'Initialization' {
123 It 'Creates result with file path' {
124 $result = New-FileValidationResult -FilePath 'test.md'
125
126 $result.FilePath | Should -Be 'test.md'
127 $result.Issues.Count | Should -Be 0
128 }
129
130 It 'Initializes with current timestamp' {
131 $result = New-FileValidationResult -FilePath 'test.md'
132
133 $result.ValidatedAt | Should -Not -BeNullOrEmpty
134 $result.ValidatedAt | Should -Match (Get-StandardTimestampPattern)
135 }
136
137 It 'Initializes Issues as empty list' {
138 $result = New-FileValidationResult -FilePath 'docs/test.md'
139
140 $result.Issues | Should -HaveCount 0
141 }
142 }
143
144 Context 'Issue tracking' {
145 It 'Tracks errors separately from warnings' {
146 $result = New-FileValidationResult -FilePath 'test.md'
147 $result.AddError('Error 1', 'field1')
148 $result.AddError('Error 2', 'field2')
149 $result.AddWarning('Warning 1', 'field3')
150
151 $result.ErrorCount() | Should -Be 2
152 $result.WarningCount() | Should -Be 1
153 }
154
155 It 'Reports HasErrors correctly' {
156 $result = New-FileValidationResult -FilePath 'test.md'
157 $result.HasErrors() | Should -BeFalse
158
159 $result.AddError('An error', 'testField')
160 $result.HasErrors() | Should -BeTrue
161 }
162
163 It 'Reports HasWarnings correctly' {
164 $result = New-FileValidationResult -FilePath 'test.md'
165 $result.HasWarnings() | Should -BeFalse
166
167 $result.AddWarning('A warning', 'testField')
168 $result.HasWarnings() | Should -BeTrue
169 }
170
171 It 'Reports IsValid correctly' {
172 $result = New-FileValidationResult -FilePath 'test.md'
173 $result.IsValid() | Should -BeTrue
174
175 $result.AddWarning('A warning', 'warnField')
176 $result.IsValid() | Should -BeTrue
177
178 $result.AddError('An error', 'errField')
179 $result.IsValid() | Should -BeFalse
180 }
181
182 It 'Adds ValidationIssue directly' {
183 $result = New-FileValidationResult -FilePath 'test.md'
184 $issue = New-ValidationIssueEmpty
185 $issue.Type = 'Error'
186 $issue.Message = 'Direct issue'
187
188 $result.AddIssue($issue)
189
190 $result.Issues.Count | Should -Be 1
191 $result.Issues[0].Message | Should -Be 'Direct issue'
192 }
193
194 It 'AddError creates issue with Error type' {
195 $result = New-FileValidationResult -FilePath 'test.md'
196 $result.AddError('Test error message', 'testField')
197
198 $result.Issues[0].Type | Should -Be 'Error'
199 $result.Issues[0].Message | Should -Be 'Test error message'
200 }
201
202 It 'AddWarning creates issue with Warning type' {
203 $result = New-FileValidationResult -FilePath 'test.md'
204 $result.AddWarning('Test warning message', 'testField')
205
206 $result.Issues[0].Type | Should -Be 'Warning'
207 $result.Issues[0].Message | Should -Be 'Test warning message'
208 }
209 }
210}
211
212#endregion
213
214#region ValidationSummary Class Tests
215
216Describe 'ValidationSummary Class' -Tag 'Unit' {
217 Context 'Aggregation' {
218 It 'Aggregates results correctly' {
219 $summary = New-ValidationSummary
220
221 $result1 = New-FileValidationResult -FilePath 'file1.md'
222 $result1.AddError('Error 1', 'field1')
223
224 $result2 = New-FileValidationResult -FilePath 'file2.md'
225 $result2.AddWarning('Warning 1', 'field2')
226
227 $result3 = New-FileValidationResult -FilePath 'file3.md'
228
229 $summary.AddResult($result1)
230 $summary.AddResult($result2)
231 $summary.AddResult($result3)
232 $summary.Complete()
233
234 $summary.TotalFiles | Should -Be 3
235 $summary.FilesWithErrors | Should -Be 1
236 $summary.FilesWithWarnings | Should -Be 1
237 $summary.FilesValid | Should -Be 1
238 $summary.TotalErrors | Should -Be 1
239 $summary.TotalWarnings | Should -Be 1
240 }
241
242 It 'Tracks duration' {
243 $summary = New-ValidationSummary
244 Start-Sleep -Milliseconds 50
245 $summary.Complete()
246
247 $summary.Duration.TotalMilliseconds | Should -BeGreaterThan 40
248 }
249
250 It 'Stores results in Results collection' {
251 $summary = New-ValidationSummary
252 $result = New-FileValidationResult -FilePath 'test.md'
253 $summary.AddResult($result)
254
255 $summary.Results.Count | Should -Be 1
256 $summary.Results[0].FilePath | Should -Be 'test.md'
257 }
258 }
259
260 Context 'Exit code calculation' {
261 It 'Returns 0 when no errors' {
262 $summary = New-ValidationSummary
263 $result = New-FileValidationResult -FilePath 'file.md'
264 $summary.AddResult($result)
265
266 $summary.GetExitCode($false) | Should -Be 0
267 }
268
269 It 'Returns 1 when errors exist' {
270 $summary = New-ValidationSummary
271 $result = New-FileValidationResult -FilePath 'file.md'
272 $result.AddError('An error', 'testField')
273 $summary.AddResult($result)
274
275 $summary.GetExitCode($false) | Should -Be 1
276 }
277
278 It 'Treats warnings as errors when flag is set' {
279 $summary = New-ValidationSummary
280 $result = New-FileValidationResult -FilePath 'file.md'
281 $result.AddWarning('A warning', 'testField')
282 $summary.AddResult($result)
283
284 $summary.GetExitCode($false) | Should -Be 0
285 $summary.GetExitCode($true) | Should -Be 1
286 }
287
288 It 'Returns 2 for empty summary (no files validated)' {
289 $summary = New-ValidationSummary
290
291 # Exit code 2 = no files validated (distinct from validation errors)
292 $summary.GetExitCode($false) | Should -Be 2
293 }
294 }
295
296 Context 'Passed method' {
297 It 'Returns true when no errors' {
298 $summary = New-ValidationSummary
299 $result = New-FileValidationResult -FilePath 'file.md'
300 $summary.AddResult($result)
301
302 $summary.Passed($false) | Should -BeTrue
303 }
304
305 It 'Returns false when errors exist' {
306 $summary = New-ValidationSummary
307 $result = New-FileValidationResult -FilePath 'file.md'
308 $result.AddError('Error', 'testField')
309 $summary.AddResult($result)
310
311 $summary.Passed($false) | Should -BeFalse
312 }
313
314 It 'Considers warnings as failures when flag is set' {
315 $summary = New-ValidationSummary
316 $result = New-FileValidationResult -FilePath 'file.md'
317 $result.AddWarning('Warning', 'testField')
318 $summary.AddResult($result)
319
320 $summary.Passed($false) | Should -BeTrue
321 $summary.Passed($true) | Should -BeFalse
322 }
323 }
324
325 Context 'Serialization' {
326 It 'Converts to hashtable' {
327 $summary = New-ValidationSummary
328 $result = New-FileValidationResult -FilePath 'test.md'
329 $result.AddError('Test error', 'testField')
330 $summary.AddResult($result)
331 $summary.Complete()
332
333 $hash = $summary.ToHashtable()
334
335 $hash.totalFiles | Should -Be 1
336 $hash.totalErrors | Should -Be 1
337 $hash.results.Count | Should -Be 1
338 $hash.results[0].issues.Count | Should -Be 1
339 }
340
341 It 'Includes duration in hashtable' {
342 $summary = New-ValidationSummary
343 $summary.Complete()
344
345 $hash = $summary.ToHashtable()
346
347 $hash.ContainsKey('duration') | Should -BeTrue
348 }
349
350 It 'Includes timestamp using Get-StandardTimestamp in hashtable' {
351 $summary = New-ValidationSummary
352 $summary.Complete()
353
354 $hash = $summary.ToHashtable()
355
356 $hash.ContainsKey('timestamp') | Should -BeTrue
357 $hash['timestamp'] | Should -Match (Get-StandardTimestampPattern)
358 }
359 }
360}
361
362#endregion
363
364#region Test-RequiredField Tests
365
366Describe 'Test-RequiredField' -Tag 'Unit' {
367 Context 'Field exists and has value' {
368 It 'Returns no issues when field is present with value' {
369 $frontmatter = @{ title = 'My Title' }
370
371 $issues = Test-RequiredField -Frontmatter $frontmatter -FieldName 'title' -RelativePath 'test.md'
372
373 $issues.Count | Should -Be 0
374 }
375 }
376
377 Context 'Field missing' {
378 It 'Returns error when field is missing' {
379 $frontmatter = @{ description = 'Has description' }
380
381 $issues = Test-RequiredField -Frontmatter $frontmatter -FieldName 'title' -RelativePath 'test.md'
382
383 $issues.Count | Should -Be 1
384 $issues[0].Type | Should -Be 'Error'
385 $issues[0].Field | Should -Be 'title'
386 $issues[0].Message | Should -Match 'Missing required field'
387 }
388 }
389
390 Context 'Field exists but empty' {
391 It 'Returns error when field is empty string' {
392 $frontmatter = @{ title = '' }
393
394 $issues = Test-RequiredField -Frontmatter $frontmatter -FieldName 'title' -RelativePath 'test.md'
395
396 $issues.Count | Should -Be 1
397 $issues[0].Type | Should -Be 'Error'
398 }
399
400 It 'Returns error when field is whitespace only' {
401 $frontmatter = @{ title = ' ' }
402
403 $issues = Test-RequiredField -Frontmatter $frontmatter -FieldName 'title' -RelativePath 'test.md'
404
405 $issues.Count | Should -Be 1
406 $issues[0].Type | Should -Be 'Error'
407 }
408
409 It 'Returns error when field is null' {
410 $frontmatter = @{ title = $null }
411
412 $issues = Test-RequiredField -Frontmatter $frontmatter -FieldName 'title' -RelativePath 'test.md'
413
414 $issues.Count | Should -Be 1
415 $issues[0].Type | Should -Be 'Error'
416 }
417 }
418
419 Context 'Custom severity' {
420 It 'Uses Warning severity when specified' {
421 $frontmatter = @{}
422
423 $issues = Test-RequiredField -Frontmatter $frontmatter -FieldName 'author' -RelativePath 'test.md' -Severity 'Warning'
424
425 $issues.Count | Should -Be 1
426 $issues[0].Type | Should -Be 'Warning'
427 }
428 }
429}
430
431#endregion
432
433#region Test-DateFormat Tests
434
435Describe 'Test-DateFormat' -Tag 'Unit' {
436 Context 'Valid date formats' {
437 It 'Returns no issues for ISO 8601 date (YYYY-MM-DD)' {
438 $frontmatter = @{ 'ms.date' = '2025-01-16' }
439
440 $issues = Test-DateFormat -Frontmatter $frontmatter -FieldName 'ms.date' -RelativePath 'test.md'
441
442 $issues.Count | Should -Be 0
443 }
444
445 It 'Returns no issues for placeholder format (YYYY-MM-dd)' {
446 $frontmatter = @{ 'ms.date' = '(YYYY-MM-dd)' }
447
448 $issues = Test-DateFormat -Frontmatter $frontmatter -FieldName 'ms.date' -RelativePath 'test.md'
449
450 $issues.Count | Should -Be 0
451 }
452
453 It 'Returns no issues when field is missing' {
454 $frontmatter = @{ title = 'Test' }
455
456 $issues = Test-DateFormat -Frontmatter $frontmatter -FieldName 'ms.date' -RelativePath 'test.md'
457
458 $issues.Count | Should -Be 0
459 }
460 }
461
462 Context 'Invalid date formats' {
463 It 'Returns warning for slash-separated date' {
464 $frontmatter = @{ 'ms.date' = '2025/01/16' }
465
466 $issues = Test-DateFormat -Frontmatter $frontmatter -FieldName 'ms.date' -RelativePath 'test.md'
467
468 $issues.Count | Should -Be 1
469 $issues[0].Type | Should -Be 'Warning'
470 $issues[0].Message | Should -Match 'Invalid date format'
471 }
472
473 It 'Returns warning for MM-DD-YYYY format' {
474 $frontmatter = @{ 'ms.date' = '01-16-2025' }
475
476 $issues = Test-DateFormat -Frontmatter $frontmatter -FieldName 'ms.date' -RelativePath 'test.md'
477
478 $issues.Count | Should -Be 1
479 $issues[0].Type | Should -Be 'Warning'
480 }
481
482 It 'Returns warning for text date' {
483 $frontmatter = @{ 'ms.date' = 'January 16, 2025' }
484
485 $issues = Test-DateFormat -Frontmatter $frontmatter -FieldName 'ms.date' -RelativePath 'test.md'
486
487 $issues.Count | Should -Be 1
488 $issues[0].Type | Should -Be 'Warning'
489 }
490 }
491}
492
493#endregion
494
495#region Test-SuggestedFields Tests
496
497Describe 'Test-SuggestedFields' -Tag 'Unit' {
498 Context 'All suggested fields present' {
499 It 'Returns no issues when all fields exist' {
500 $frontmatter = @{
501 author = 'test-author'
502 'ms.date' = '2025-01-16'
503 }
504 $fieldNames = @('author', 'ms.date')
505
506 $issues = Test-SuggestedFields -Frontmatter $frontmatter -FieldNames $fieldNames -RelativePath 'test.md'
507
508 $issues.Count | Should -Be 0
509 }
510 }
511
512 Context 'Missing suggested fields' {
513 It 'Returns warning for each missing field' {
514 $frontmatter = @{ title = 'Test' }
515 $fieldNames = @('author', 'ms.date', 'ms.topic')
516
517 $issues = Test-SuggestedFields -Frontmatter $frontmatter -FieldNames $fieldNames -RelativePath 'test.md'
518
519 $issues.Count | Should -Be 3
520 $issues | ForEach-Object { $_.Type | Should -Be 'Warning' }
521 }
522
523 It 'Returns warning with field name in message' {
524 $frontmatter = @{}
525 $fieldNames = @('author')
526
527 $issues = Test-SuggestedFields -Frontmatter $frontmatter -FieldNames $fieldNames -RelativePath 'test.md'
528
529 $issues[0].Field | Should -Be 'author'
530 $issues[0].Message | Should -Match 'author'
531 }
532 }
533
534 Context 'Partial fields present' {
535 It 'Returns warnings only for missing fields' {
536 $frontmatter = @{
537 author = 'test'
538 'ms.topic' = 'overview'
539 }
540 $fieldNames = @('author', 'ms.date', 'ms.topic')
541
542 $issues = Test-SuggestedFields -Frontmatter $frontmatter -FieldNames $fieldNames -RelativePath 'test.md'
543
544 $issues.Count | Should -Be 1
545 $issues[0].Field | Should -Be 'ms.date'
546 }
547 }
548}
549
550#endregion
551
552#region Test-RootCommunityFileFields Tests
553
554Describe 'Test-RootCommunityFileFields' -Tag 'Unit' {
555 Context 'Valid frontmatter' {
556 It 'Returns only warnings for complete frontmatter with all fields' {
557 $frontmatter = @{
558 title = 'Contributing Guide'
559 description = 'How to contribute to this project'
560 author = 'maintainer'
561 'ms.date' = '2025-01-16'
562 }
563
564 $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath 'CONTRIBUTING.md'
565
566 $errors = $issues | Where-Object { $_.Type -eq 'Error' }
567 $errors.Count | Should -Be 0
568 }
569 }
570
571 Context 'Missing required fields' {
572 It 'Returns error for missing title' {
573 $frontmatter = @{ description = 'Valid description' }
574
575 $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath 'README.md'
576
577 $errors = $issues | Where-Object { $_.Type -eq 'Error' -and $_.Field -eq 'title' }
578 $errors.Count | Should -Be 1
579 }
580
581 It 'Returns error for missing description' {
582 $frontmatter = @{ title = 'Valid title' }
583
584 $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath 'README.md'
585
586 $errors = $issues | Where-Object { $_.Type -eq 'Error' -and $_.Field -eq 'description' }
587 $errors.Count | Should -Be 1
588 }
589 }
590
591 Context 'Missing suggested fields' {
592 It 'Returns warnings for missing author and ms.date' {
593 $frontmatter = @{
594 title = 'Test'
595 description = 'Test desc'
596 }
597
598 $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath 'SECURITY.md'
599
600 $warnings = $issues | Where-Object { $_.Type -eq 'Warning' }
601 $warnings.Count | Should -BeGreaterOrEqual 2
602 }
603 }
604
605 Context 'Invalid date format' {
606 It 'Returns warning for invalid ms.date format' {
607 $frontmatter = @{
608 title = 'Test'
609 description = 'Test'
610 author = 'test'
611 'ms.date' = '2025/01/16'
612 }
613
614 $issues = Test-RootCommunityFileFields -Frontmatter $frontmatter -RelativePath 'CODE_OF_CONDUCT.md'
615
616 $dateWarnings = $issues | Where-Object { $_.Field -eq 'ms.date' -and $_.Type -eq 'Warning' }
617 $dateWarnings.Count | Should -Be 1
618 }
619 }
620}
621
622#endregion
623
624#region Test-DevContainerFileFields Tests
625
626Describe 'Test-DevContainerFileFields' -Tag 'Unit' {
627 Context 'Valid frontmatter' {
628 It 'Returns no issues for complete frontmatter' {
629 $frontmatter = @{
630 title = 'Dev Container Setup'
631 description = 'Development container configuration'
632 }
633
634 $issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath '.devcontainer/README.md'
635
636 $issues.Count | Should -Be 0
637 }
638 }
639
640 Context 'Missing required fields' {
641 It 'Returns error for missing title' {
642 $frontmatter = @{ description = 'Valid' }
643
644 $issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath '.devcontainer/README.md'
645
646 $issues.Count | Should -Be 1
647 $issues[0].Field | Should -Be 'title'
648 $issues[0].Type | Should -Be 'Error'
649 }
650
651 It 'Returns error for missing description' {
652 $frontmatter = @{ title = 'Valid' }
653
654 $issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath '.devcontainer/README.md'
655
656 $issues.Count | Should -Be 1
657 $issues[0].Field | Should -Be 'description'
658 }
659
660 It 'Returns two errors when both fields missing' {
661 $frontmatter = @{}
662
663 $issues = Test-DevContainerFileFields -Frontmatter $frontmatter -RelativePath '.devcontainer/README.md'
664
665 $issues.Count | Should -Be 2
666 }
667 }
668}
669
670#endregion
671
672#region Test-VSCodeReadmeFileFields Tests
673
674Describe 'Test-VSCodeReadmeFileFields' -Tag 'Unit' {
675 Context 'Valid frontmatter' {
676 It 'Returns no issues for complete frontmatter' {
677 $frontmatter = @{
678 title = 'Extension README'
679 description = 'VS Code extension documentation'
680 }
681
682 $issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath 'extension/README.md'
683
684 $issues.Count | Should -Be 0
685 }
686 }
687
688 Context 'Missing required fields' {
689 It 'Returns error for missing title' {
690 $frontmatter = @{ description = 'Valid' }
691
692 $issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath '.vscode/README.md'
693
694 $errors = $issues | Where-Object { $_.Field -eq 'title' }
695 $errors.Count | Should -Be 1
696 }
697
698 It 'Returns error for missing description' {
699 $frontmatter = @{ title = 'Valid' }
700
701 $issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath '.vscode/README.md'
702
703 $errors = $issues | Where-Object { $_.Field -eq 'description' }
704 $errors.Count | Should -Be 1
705 }
706
707 It 'Returns two errors when both fields missing' {
708 $frontmatter = @{}
709
710 $issues = Test-VSCodeReadmeFileFields -Frontmatter $frontmatter -RelativePath '.vscode/README.md'
711
712 $issues.Count | Should -Be 2
713 }
714 }
715}
716
717#endregion
718
719#region Test-FooterPresence Tests
720
721Describe 'Test-FooterPresence' -Tag 'Unit' {
722 Context 'Footer present' {
723 It 'Returns null when footer is present' {
724 $issue = Test-FooterPresence -HasFooter $true -RelativePath '.vscode/README.md'
725
726 $issue | Should -BeNullOrEmpty
727 }
728 }
729
730 Context 'Footer missing' {
731 It 'Returns error when footer is missing' {
732 $issue = Test-FooterPresence -HasFooter $false -RelativePath '.vscode/README.md'
733
734 $issue | Should -Not -BeNullOrEmpty
735 $issue.Type | Should -Be 'Error'
736 $issue.Field | Should -Be 'footer'
737 }
738
739 It 'Uses Warning severity when specified' {
740 $issue = Test-FooterPresence -HasFooter $false -RelativePath 'test.md' -Severity 'Warning'
741
742 $issue.Type | Should -Be 'Warning'
743 }
744 }
745}
746
747#endregion
748
749#region Test-GitHubResourceFileFields Tests
750
751Describe 'Test-GitHubResourceFileFields' -Tag 'Unit' {
752 BeforeAll {
753 # Create FileTypeInfo mock objects using module scope to avoid
754 # class identity conflicts between using module and Import-Module
755 $script:ChatModeInfo = New-FileTypeInfo -Properties @{ IsChatMode = $true; IsGitHub = $true }
756 $script:InstructionInfo = New-FileTypeInfo -Properties @{ IsInstruction = $true; IsGitHub = $true }
757 $script:PromptInfo = New-FileTypeInfo -Properties @{ IsPrompt = $true; IsGitHub = $true }
758 }
759
760 Context 'ChatMode/Agent files' {
761 It 'Returns warning when description missing for agent file' {
762 $frontmatter = @{ name = 'Test Agent' }
763
764 $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -RelativePath '.github/agents/test.agent.md' -FileTypeInfo $script:ChatModeInfo
765
766 $issues.Count | Should -Be 1
767 $issues[0].Type | Should -Be 'Warning'
768 $issues[0].Field | Should -Be 'description'
769 }
770
771 It 'Returns no issues when description present for agent file' {
772 $frontmatter = @{ description = 'Agent description' }
773
774 $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -RelativePath '.github/agents/test.chatmode.md' -FileTypeInfo $script:ChatModeInfo
775
776 $issues.Count | Should -Be 0
777 }
778 }
779
780 Context 'Instruction files' {
781 It 'Returns error when description missing for instruction file' {
782 $frontmatter = @{ title = 'Test' }
783
784 $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -RelativePath '.github/instructions/test.instructions.md' -FileTypeInfo $script:InstructionInfo
785
786 $issues.Count | Should -Be 1
787 $issues[0].Type | Should -Be 'Error'
788 $issues[0].Field | Should -Be 'description'
789 }
790
791 It 'Returns no issues when description present for instruction file' {
792 $frontmatter = @{ description = 'Instruction description' }
793
794 $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -RelativePath '.github/instructions/test.instructions.md' -FileTypeInfo $script:InstructionInfo
795
796 $issues.Count | Should -Be 0
797 }
798 }
799
800 Context 'Prompt files' {
801 It 'Returns no issues for prompt files (freeform content)' {
802 $frontmatter = @{}
803
804 $issues = Test-GitHubResourceFileFields -Frontmatter $frontmatter -RelativePath '.github/prompts/test.prompt.md' -FileTypeInfo $script:PromptInfo
805
806 $issues.Count | Should -Be 0
807 }
808 }
809}
810
811#endregion
812
813#region Test-DocsFileFields Tests
814
815Describe 'Test-DocsFileFields' -Tag 'Unit' {
816 Context 'Valid frontmatter' {
817 It 'Returns only warnings for complete frontmatter' {
818 $frontmatter = @{
819 title = 'Getting Started'
820 description = 'How to get started with the project'
821 author = 'docs-team'
822 'ms.date' = '2025-01-16'
823 'ms.topic' = 'overview'
824 }
825
826 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/getting-started.md'
827
828 $errors = $issues | Where-Object { $_.Type -eq 'Error' }
829 $errors.Count | Should -Be 0
830 }
831 }
832
833 Context 'Missing required fields' {
834 It 'Returns error for missing title' {
835 $frontmatter = @{ description = 'Valid' }
836
837 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/test.md'
838
839 $errors = $issues | Where-Object { $_.Type -eq 'Error' -and $_.Field -eq 'title' }
840 $errors.Count | Should -Be 1
841 }
842
843 It 'Returns error for missing description' {
844 $frontmatter = @{ title = 'Valid' }
845
846 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/test.md'
847
848 $errors = $issues | Where-Object { $_.Type -eq 'Error' -and $_.Field -eq 'description' }
849 $errors.Count | Should -Be 1
850 }
851 }
852
853 Context 'Missing suggested fields' {
854 It 'Returns warnings for missing author, ms.date, ms.topic' {
855 $frontmatter = @{
856 title = 'Test'
857 description = 'Test'
858 }
859
860 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/test.md'
861
862 $warnings = $issues | Where-Object { $_.Type -eq 'Warning' }
863 $warnings.Count | Should -BeGreaterOrEqual 3
864 }
865 }
866
867 Context 'Invalid ms.topic value' {
868 It 'Returns warning for unknown topic type' {
869 $frontmatter = @{
870 title = 'Test'
871 description = 'Test'
872 'ms.topic' = 'invalid-topic'
873 }
874
875 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/test.md'
876
877 $topicWarnings = $issues | Where-Object { $_.Field -eq 'ms.topic' }
878 $topicWarnings.Count | Should -Be 1
879 $topicWarnings[0].Message | Should -Match 'Unknown topic type'
880 }
881
882 It 'Returns no warning for valid topic types' {
883 $validTopics = @('overview', 'concept', 'tutorial', 'reference', 'how-to', 'troubleshooting')
884
885 foreach ($topic in $validTopics) {
886 $frontmatter = @{
887 title = 'Test'
888 description = 'Test'
889 'ms.topic' = $topic
890 }
891
892 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/test.md'
893
894 $topicWarnings = $issues | Where-Object { $_.Field -eq 'ms.topic' -and $_.Message -match 'Unknown' }
895 $topicWarnings.Count | Should -Be 0 -Because "Topic '$topic' should be valid"
896 }
897 }
898 }
899
900 Context 'Invalid date format' {
901 It 'Returns warning for invalid ms.date format' {
902 $frontmatter = @{
903 title = 'Test'
904 description = 'Test'
905 'ms.date' = 'Jan 16, 2025'
906 }
907
908 $issues = Test-DocsFileFields -Frontmatter $frontmatter -RelativePath 'docs/test.md'
909
910 $dateWarnings = $issues | Where-Object { $_.Field -eq 'ms.date' -and $_.Message -match 'Invalid date' }
911 $dateWarnings.Count | Should -Be 1
912 }
913 }
914}
915
916#endregion
917
918#region Main Script Function Tests
919
920# Tests for functions in Validate-MarkdownFrontmatter.ps1
921Describe 'Main Script Functions' -Tag 'Unit' {
922 BeforeAll {
923 # Dot-source the main script to access its functions
924 $script:MainScriptPath = Join-Path $PSScriptRoot '..\..\linting\Validate-MarkdownFrontmatter.ps1'
925 # Source the script with minimal parameters to avoid executing main logic
926 . $script:MainScriptPath -Paths @() -ErrorAction SilentlyContinue 2>$null
927 }
928
929 # Note: ConvertFrom-YamlFrontmatter and Get-MarkdownFrontmatter tests removed
930 # Those functions were deleted as part of issue #266 refactoring - functionality
931 # now provided by FrontmatterValidation.psm1
932
933 Context 'Test-MarkdownFooter' {
934 It 'Returns $true when standard Copilot footer present' {
935 $content = @"
936# Test
937
938Content here.
939
940🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.
941"@
942 $result = Test-MarkdownFooter -Content $content
943
944 $result | Should -BeTrue
945 }
946
947 It 'Returns $true when footer is bold formatted' {
948 $content = @"
949# Test
950
951Content.
952
953**🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.**
954"@
955 $result = Test-MarkdownFooter -Content $content
956
957 $result | Should -BeTrue
958 }
959
960 It 'Returns $false when no footer present' {
961 $content = @"
962# Test
963
964Content without footer.
965"@
966 $result = Test-MarkdownFooter -Content $content
967
968 $result | Should -BeFalse
969 }
970
971 It 'Returns $true when footer wrapped in HTML comment markers' {
972 $content = @"
973# Test
974
975<!-- markdownlint-disable -->
976🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.
977<!-- markdownlint-enable -->
978"@
979 $result = Test-MarkdownFooter -Content $content
980
981 $result | Should -BeTrue
982 }
983 }
984
985 Context 'Initialize-JsonSchemaValidation' {
986 It 'Returns $true when JSON processing available' {
987 $result = Initialize-JsonSchemaValidation
988
989 $result | Should -BeTrue
990 }
991 }
992
993 Context 'Get-SchemaForFile' {
994 BeforeAll {
995 $script:SchemaDir = Join-Path $PSScriptRoot '..\..\linting\schemas'
996 }
997
998 It 'Returns docs schema for docs/ files' {
999 $result = Get-SchemaForFile -FilePath 'docs/getting-started.md' -SchemaDirectory $script:SchemaDir
1000
1001 $result | Should -Not -BeNullOrEmpty
1002 $result | Should -Match 'docs-frontmatter'
1003 }
1004
1005 It 'Returns instruction schema for .instructions.md files' {
1006 $result = Get-SchemaForFile -FilePath '.github/instructions/test.instructions.md' -SchemaDirectory $script:SchemaDir
1007
1008 $result | Should -Not -BeNullOrEmpty
1009 $result | Should -Match 'instruction'
1010 }
1011
1012 It 'Returns default schema for unmapped files' {
1013 $result = Get-SchemaForFile -FilePath 'random/file.md' -SchemaDirectory $script:SchemaDir
1014
1015 # Function returns defaultSchema from mapping, not null
1016 $result | Should -Not -BeNullOrEmpty
1017 $result | Should -Match 'base-frontmatter'
1018 }
1019 }
1020}
1021
1022#endregion
1023
1024#region Test-CommonFields Tests
1025
1026Describe 'Test-CommonFields' -Tag 'Unit' {
1027 Context 'Keywords validation' {
1028 It 'Returns no issues when keywords is an array' {
1029 $frontmatter = @{
1030 keywords = @('powershell', 'validation', 'frontmatter')
1031 }
1032
1033 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1034
1035 $keywordIssues = $issues | Where-Object { $_.Field -eq 'keywords' }
1036 $keywordIssues.Count | Should -Be 0
1037 }
1038
1039 It 'Returns no issues when keywords contains comma (treated as list)' {
1040 $frontmatter = @{
1041 keywords = 'powershell, validation, frontmatter'
1042 }
1043
1044 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1045
1046 $keywordIssues = $issues | Where-Object { $_.Field -eq 'keywords' }
1047 $keywordIssues.Count | Should -Be 0
1048 }
1049
1050 It 'Returns warning when keywords is single string without comma' {
1051 $frontmatter = @{
1052 keywords = 'single-keyword'
1053 }
1054
1055 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1056
1057 $keywordIssues = $issues | Where-Object { $_.Field -eq 'keywords' }
1058 $keywordIssues.Count | Should -Be 1
1059 $keywordIssues[0].Type | Should -Be 'Warning'
1060 }
1061 }
1062
1063 Context 'Estimated reading time validation' {
1064 It 'Returns no issues for valid integer reading time' {
1065 $frontmatter = @{
1066 estimated_reading_time = '5'
1067 }
1068
1069 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1070
1071 $readingTimeIssues = $issues | Where-Object { $_.Field -eq 'estimated_reading_time' }
1072 $readingTimeIssues.Count | Should -Be 0
1073 }
1074
1075 It 'Returns warning for non-integer reading time' {
1076 $frontmatter = @{
1077 estimated_reading_time = '5 minutes'
1078 }
1079
1080 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1081
1082 $readingTimeIssues = $issues | Where-Object { $_.Field -eq 'estimated_reading_time' }
1083 $readingTimeIssues.Count | Should -Be 1
1084 $readingTimeIssues[0].Type | Should -Be 'Warning'
1085 }
1086
1087 It 'Returns warning for decimal reading time' {
1088 $frontmatter = @{
1089 estimated_reading_time = '5.5'
1090 }
1091
1092 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1093
1094 $readingTimeIssues = $issues | Where-Object { $_.Field -eq 'estimated_reading_time' }
1095 $readingTimeIssues.Count | Should -Be 1
1096 }
1097 }
1098
1099 Context 'No optional fields' {
1100 It 'Returns no issues when optional fields are missing' {
1101 $frontmatter = @{
1102 title = 'Test'
1103 description = 'Test'
1104 }
1105
1106 $issues = Test-CommonFields -Frontmatter $frontmatter -RelativePath 'test.md'
1107
1108 $issues.Count | Should -Be 0
1109 }
1110 }
1111}
1112
1113#endregion
1114
1115#region Test-MarkdownFooter Tests
1116
1117Describe 'Test-MarkdownFooter' -Tag 'Unit' {
1118 Context 'Valid footer detection' {
1119 It 'Returns true for standard footer' {
1120 $content = @"
1121# Content
1122
1123🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.
1124"@
1125 Test-MarkdownFooter -Content $content | Should -BeTrue
1126 }
1127
1128 It 'Returns true for footer with then keyword' {
1129 $content = @"
1130# Content
1131
1132🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers.
1133"@
1134 Test-MarkdownFooter -Content $content | Should -BeTrue
1135 }
1136
1137 It 'Returns true for bold formatted footer' {
1138 $content = @"
1139# Content
1140
1141**🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.**
1142"@
1143 Test-MarkdownFooter -Content $content | Should -BeTrue
1144 }
1145
1146 It 'Returns true for footer wrapped in HTML comments' {
1147 $content = @"
1148# Content
1149
1150<!-- markdownlint-disable -->
1151🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.
1152<!-- markdownlint-enable -->
1153"@
1154 Test-MarkdownFooter -Content $content | Should -BeTrue
1155 }
1156 }
1157
1158 Context 'Missing or invalid footer' {
1159 It 'Returns false for empty content' {
1160 Test-MarkdownFooter -Content '' | Should -BeFalse
1161 }
1162
1163 It 'Returns false for content without footer' {
1164 $content = @"
1165# Content
1166
1167Just some regular content here.
1168"@
1169 Test-MarkdownFooter -Content $content | Should -BeFalse
1170 }
1171
1172 It 'Returns false for partial footer' {
1173 $content = @"
1174# Content
1175
1176🤖 Crafted with precision by ✨Copilot
1177"@
1178 Test-MarkdownFooter -Content $content | Should -BeFalse
1179 }
1180 }
1181}
1182
1183#endregion
1184
1185#region Test-SingleFileFrontmatter Tests
1186
1187Describe 'Test-SingleFileFrontmatter' -Tag 'Unit' {
1188 BeforeAll {
1189 # Use TestDrive for cross-platform compatibility (Linux CI runners)
1190 $script:TestRepoRoot = Join-Path $TestDrive 'test-repo'
1191 New-Item -ItemType Directory -Path $script:TestRepoRoot -Force | Out-Null
1192 }
1193
1194 Context 'Valid docs file' {
1195 It 'Returns result with no errors for valid frontmatter' {
1196 $mockContent = @"
1197---
1198title: Test Document
1199description: A test file description
1200ms.date: 01/15/2025
1201author: testuser
1202ms.topic: concept
1203---
1204
1205# Content
1206
1207🤖 Crafted with precision by ✨Copilot following brilliant human instruction, carefully refined by our team of discerning human reviewers.
1208"@
1209 $testFile = Join-Path $script:TestRepoRoot 'docs' 'test.md'
1210 $result = Test-SingleFileFrontmatter `
1211 -FilePath $testFile `
1212 -RepoRoot $script:TestRepoRoot `
1213 -FileReader { $mockContent }.GetNewClosure()
1214
1215 $result | Should -Not -BeNull
1216 $result.HasFrontmatter | Should -BeTrue
1217 $result.Frontmatter.title | Should -Be 'Test Document'
1218 $result.IsValid() | Should -BeTrue
1219 }
1220 }
1221
1222 Context 'Missing frontmatter' {
1223 It 'Returns warning for file without frontmatter' {
1224 $testFile = Join-Path $script:TestRepoRoot 'docs' 'test.md'
1225 $result = Test-SingleFileFrontmatter `
1226 -FilePath $testFile `
1227 -RepoRoot $script:TestRepoRoot `
1228 -FileReader { '# Just a heading' }
1229
1230 $result.HasFrontmatter | Should -BeFalse
1231 $result.HasWarnings() | Should -BeTrue
1232 $result.Issues[0].Message | Should -BeLike '*No frontmatter*'
1233 }
1234 }
1235
1236 Context 'Invalid YAML' {
1237 It 'Returns error for malformed YAML' {
1238 $mockContent = @"
1239---
1240title: Test
1241bad yaml: [unclosed
1242---
1243# Content
1244"@
1245 $testFile = Join-Path $script:TestRepoRoot 'docs' 'test.md'
1246 $result = Test-SingleFileFrontmatter `
1247 -FilePath $testFile `
1248 -RepoRoot $script:TestRepoRoot `
1249 -FileReader { $mockContent }.GetNewClosure()
1250
1251 $result.HasErrors() | Should -BeTrue
1252 $result.Issues[0].Message | Should -BeLike '*YAML*'
1253 }
1254 }
1255
1256 Context 'File read error' {
1257 It 'Returns error when file cannot be read' {
1258 $testFile = Join-Path $script:TestRepoRoot 'docs' 'missing.md'
1259 $result = Test-SingleFileFrontmatter `
1260 -FilePath $testFile `
1261 -RepoRoot $script:TestRepoRoot `
1262 -FileReader { throw 'File not found' }
1263
1264 $result.HasErrors() | Should -BeTrue
1265 $result.Issues[0].Message | Should -BeLike '*Failed to read*'
1266 }
1267 }
1268
1269 Context 'File type detection' {
1270 It 'Detects docs file correctly' {
1271 $mockContent = @"
1272---
1273title: Test
1274description: Test desc
1275---
1276# Content
1277"@
1278 $testFile = Join-Path $script:TestRepoRoot 'docs' 'guide.md'
1279 $result = Test-SingleFileFrontmatter `
1280 -FilePath $testFile `
1281 -RepoRoot $script:TestRepoRoot `
1282 -FileReader { $mockContent }.GetNewClosure()
1283
1284 $result.FileType | Should -Not -BeNull
1285 $result.FileType.IsDocsFile | Should -BeTrue
1286 }
1287
1288 It 'Detects instructions file correctly' {
1289 $mockContent = @"
1290---
1291description: Test instruction
1292---
1293# Content
1294"@
1295 $testFile = Join-Path $script:TestRepoRoot '.github' 'instructions' 'test.instructions.md'
1296 $result = Test-SingleFileFrontmatter `
1297 -FilePath $testFile `
1298 -RepoRoot $script:TestRepoRoot `
1299 -FileReader { $mockContent }.GetNewClosure()
1300
1301 $result.FileType | Should -Not -BeNull
1302 $result.FileType.IsInstruction | Should -BeTrue
1303 }
1304 }
1305
1306 Context 'Relative path computation' {
1307 It 'Computes correct relative path' {
1308 $mockContent = @"
1309---
1310title: Test
1311description: Test
1312---
1313"@
1314 $testFile = Join-Path $script:TestRepoRoot 'docs' 'subdir' 'file.md'
1315 $result = Test-SingleFileFrontmatter `
1316 -FilePath $testFile `
1317 -RepoRoot $script:TestRepoRoot `
1318 -FileReader { $mockContent }.GetNewClosure()
1319
1320 # Use platform-specific path separator for assertion
1321 $expectedPath = 'docs' + [IO.Path]::DirectorySeparatorChar + 'subdir' + [IO.Path]::DirectorySeparatorChar + 'file.md'
1322 $result.RelativePath | Should -Be $expectedPath
1323 }
1324 }
1325
1326 Context 'Footer exclude paths' {
1327 It 'Skips footer validation for file matching exclusion pattern' {
1328 $mockContent = @"
1329---
1330title: Changelog
1331description: Release history
1332---
1333
1334# Changelog
1335
1336No Copilot footer here
1337"@
1338 $testFile = Join-Path $script:TestRepoRoot 'CHANGELOG.md'
1339 $result = Test-SingleFileFrontmatter `
1340 -FilePath $testFile `
1341 -RepoRoot $script:TestRepoRoot `
1342 -FooterExcludePaths @('CHANGELOG.md') `
1343 -FileReader { $mockContent }.GetNewClosure()
1344
1345 # File without footer should NOT have footer error when excluded
1346 $footerIssues = $result.Issues | Where-Object { $_.Field -eq 'footer' }
1347 $footerIssues | Should -BeNullOrEmpty
1348 }
1349
1350 It 'Applies footer validation for non-excluded files' {
1351 $mockContent = @"
1352---
1353title: Test Doc
1354description: Test description
1355---
1356
1357# Content
1358
1359No Copilot footer here
1360"@
1361 $testFile = Join-Path $script:TestRepoRoot 'docs' 'guide.md'
1362 $result = Test-SingleFileFrontmatter `
1363 -FilePath $testFile `
1364 -RepoRoot $script:TestRepoRoot `
1365 -FooterExcludePaths @('CHANGELOG.md') `
1366 -FileReader { $mockContent }.GetNewClosure()
1367
1368 # Non-excluded file without footer should have footer error
1369 $footerIssues = $result.Issues | Where-Object { $_.Field -eq 'footer' }
1370 $footerIssues | Should -Not -BeNullOrEmpty
1371 }
1372
1373 It 'Supports wildcard patterns in exclusions' {
1374 $mockContent = @"
1375---
1376title: Test
1377description: Test
1378---
1379
1380No footer
1381"@
1382 $testFile = Join-Path $script:TestRepoRoot 'logs' 'output.md'
1383 $result = Test-SingleFileFrontmatter `
1384 -FilePath $testFile `
1385 -RepoRoot $script:TestRepoRoot `
1386 -FooterExcludePaths @('logs/*.md') `
1387 -FileReader { $mockContent }.GetNewClosure()
1388
1389 $footerIssues = $result.Issues | Where-Object { $_.Field -eq 'footer' }
1390 $footerIssues | Should -BeNullOrEmpty
1391 }
1392 }
1393}
1394
1395Describe 'Invoke-FrontmatterValidation' -Tag 'Unit' {
1396 BeforeAll {
1397 # Use TestDrive for cross-platform compatibility (Linux CI runners)
1398 $script:TestRepoRoot = Join-Path $TestDrive 'TestRepo'
1399 New-Item -ItemType Directory -Path $script:TestRepoRoot -Force | Out-Null
1400 # Get module reference for mock object creation
1401 $script:MockModule = Get-Module FrontmatterValidation
1402 }
1403
1404 Context 'Multi-file orchestration' {
1405 It 'Returns ValidationSummary object' {
1406 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1407 & (Get-Module FrontmatterValidation) {
1408 param($path)
1409 $r = [FileValidationResult]::new($path)
1410 $r.HasFrontmatter = $true
1411 return $r
1412 } $FilePath
1413 }
1414
1415 $summary = Invoke-FrontmatterValidation `
1416 -Files @("$script:TestRepoRoot\file1.md", "$script:TestRepoRoot\file2.md") `
1417 -RepoRoot $script:TestRepoRoot
1418
1419 $summary | Should -Not -BeNull
1420 $summary.TotalFiles | Should -Be 2
1421 }
1422
1423 It 'Aggregates results from multiple files' {
1424 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1425 & (Get-Module FrontmatterValidation) {
1426 param($path)
1427 $r = [FileValidationResult]::new($path)
1428 $r.HasFrontmatter = $true
1429 return $r
1430 } $FilePath
1431 }
1432
1433 $files = @(
1434 "$script:TestRepoRoot\docs\file1.md",
1435 "$script:TestRepoRoot\docs\file2.md",
1436 "$script:TestRepoRoot\docs\file3.md"
1437 )
1438
1439 $summary = Invoke-FrontmatterValidation -Files $files -RepoRoot $script:TestRepoRoot
1440
1441 $summary.TotalFiles | Should -Be 3
1442 $summary.FilesValid | Should -Be 3
1443 }
1444
1445 It 'Tracks files with warnings' {
1446 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1447 & (Get-Module FrontmatterValidation) {
1448 param($path)
1449 $r = [FileValidationResult]::new($path)
1450 $r.HasFrontmatter = $false
1451 $r.AddWarning('No frontmatter found', 'frontmatter')
1452 return $r
1453 } $FilePath
1454 }
1455
1456 $summary = Invoke-FrontmatterValidation `
1457 -Files @("$script:TestRepoRoot\plain.md") `
1458 -RepoRoot $script:TestRepoRoot
1459
1460 $summary.FilesValid | Should -Be 0
1461 $summary.FilesWithWarnings | Should -Be 1
1462 $summary.TotalFiles | Should -Be 1
1463 }
1464
1465 It 'Tracks errors across files' {
1466 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1467 & (Get-Module FrontmatterValidation) {
1468 param($path)
1469 $r = [FileValidationResult]::new($path)
1470 $r.AddError('Parse error', 'yaml')
1471 return $r
1472 } $FilePath
1473 }
1474
1475 $summary = Invoke-FrontmatterValidation `
1476 -Files @("$script:TestRepoRoot\bad1.md", "$script:TestRepoRoot\bad2.md") `
1477 -RepoRoot $script:TestRepoRoot
1478
1479 $summary.TotalErrors | Should -Be 2
1480 $summary.FilesWithErrors | Should -Be 2
1481 }
1482
1483 It 'Completes summary after processing' {
1484 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1485 & (Get-Module FrontmatterValidation) {
1486 param($path)
1487 $r = [FileValidationResult]::new($path)
1488 $r.HasFrontmatter = $true
1489 return $r
1490 } $FilePath
1491 }
1492
1493 $summary = Invoke-FrontmatterValidation `
1494 -Files @("$script:TestRepoRoot\file.md") `
1495 -RepoRoot $script:TestRepoRoot
1496
1497 $summary.CompletedAt | Should -Not -Be ([datetime]::MinValue)
1498 $summary.Duration | Should -Not -BeNull
1499 }
1500 }
1501
1502 Context 'Single file handling' {
1503 It 'Handles single file' {
1504 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1505 & (Get-Module FrontmatterValidation) {
1506 param($path)
1507 $r = [FileValidationResult]::new($path)
1508 $r.HasFrontmatter = $true
1509 return $r
1510 } $FilePath
1511 }
1512
1513 $summary = Invoke-FrontmatterValidation `
1514 -Files @("$script:TestRepoRoot\single.md") `
1515 -RepoRoot $script:TestRepoRoot
1516
1517 $summary.TotalFiles | Should -Be 1
1518 }
1519 }
1520
1521 Context 'FooterExcludePaths threading' {
1522 It 'Passes FooterExcludePaths to Test-SingleFileFrontmatter' {
1523 $capturedParams = @{}
1524
1525 Mock Test-SingleFileFrontmatter -ModuleName FrontmatterValidation {
1526 $capturedParams.FooterExcludePaths = $FooterExcludePaths
1527 & (Get-Module FrontmatterValidation) {
1528 param($path)
1529 $r = [FileValidationResult]::new($path)
1530 $r.HasFrontmatter = $true
1531 return $r
1532 } $FilePath
1533 }
1534
1535 $null = Invoke-FrontmatterValidation `
1536 -Files @("$script:TestRepoRoot\file.md") `
1537 -RepoRoot $script:TestRepoRoot `
1538 -FooterExcludePaths @('CHANGELOG.md', 'logs/*.md')
1539
1540 $capturedParams.FooterExcludePaths | Should -Be @('CHANGELOG.md', 'logs/*.md')
1541 }
1542 }
1543}
1544
1545#endregion
1546
1547#region Output Functions
1548
1549Describe 'Write-ValidationConsoleOutput' -Tag 'Unit' {
1550 It 'Writes summary without error' {
1551 $summary = script:New-ValidationSummary
1552 $result = script:New-FileValidationResult -FilePath 'test.md'
1553 $summary.AddResult($result)
1554 $summary.Complete()
1555
1556 { Write-ValidationConsoleOutput -Summary $summary } | Should -Not -Throw
1557 }
1558
1559 It 'Handles ShowDetails switch' {
1560 $summary = script:New-ValidationSummary
1561 $result = script:New-FileValidationResult -FilePath 'test.md'
1562 $result.AddWarning('Test warning', 'field')
1563 $summary.AddResult($result)
1564 $summary.Complete()
1565
1566 { Write-ValidationConsoleOutput -Summary $summary -ShowDetails } | Should -Not -Throw
1567 }
1568
1569 It 'Displays valid file icon in details mode' {
1570 $summary = script:New-ValidationSummary
1571 $result = script:New-FileValidationResult -FilePath 'valid.md'
1572 $result.HasFrontmatter = $true
1573 $summary.AddResult($result)
1574 $summary.Complete()
1575
1576 # Verify no error thrown with valid file
1577 { Write-ValidationConsoleOutput -Summary $summary -ShowDetails } | Should -Not -Throw
1578 }
1579}
1580
1581Describe 'Write-CIAnnotations' -Tag 'Unit' {
1582 BeforeAll {
1583 $script:CIHelpersModulePath = Join-Path $PSScriptRoot '..\..\lib\Modules\CIHelpers.psm1'
1584 Import-Module $script:CIHelpersModulePath -Force
1585 }
1586
1587 BeforeEach {
1588 $script:OriginalGHActions = $env:GITHUB_ACTIONS
1589 $env:GITHUB_ACTIONS = 'true'
1590 }
1591
1592 AfterEach {
1593 if ($null -eq $script:OriginalGHActions) {
1594 Remove-Item Env:GITHUB_ACTIONS -ErrorAction SilentlyContinue
1595 }
1596 else {
1597 $env:GITHUB_ACTIONS = $script:OriginalGHActions
1598 }
1599 }
1600 It 'Outputs correct error annotation format' {
1601 $summary = script:New-ValidationSummary
1602 $result = script:New-FileValidationResult -FilePath 'test.md'
1603 $result.AddError('Test error', 'field')
1604 $summary.AddResult($result)
1605
1606 $output = Write-CIAnnotations -Summary $summary
1607 $output | Should -BeLike '::error file=test.md*::Test error'
1608 }
1609
1610 It 'Outputs warnings correctly' {
1611 $summary = script:New-ValidationSummary
1612 $result = script:New-FileValidationResult -FilePath 'test.md'
1613 $result.AddWarning('Test warning', 'field')
1614 $summary.AddResult($result)
1615
1616 $output = Write-CIAnnotations -Summary $summary
1617 $output | Should -BeLike '::warning file=test.md*::Test warning'
1618 }
1619
1620 It 'Includes line number when available' {
1621 $summary = script:New-ValidationSummary
1622 $result = script:New-FileValidationResult -FilePath 'test.md'
1623 # Use AddError overload with line number
1624 $result.AddError('Error at line', 'field', 42)
1625 $summary.AddResult($result)
1626
1627 $output = Write-CIAnnotations -Summary $summary
1628 $output | Should -BeLike '::error file=test.md,line=42::Error at line'
1629 }
1630
1631 It 'Returns nothing when no issues' {
1632 $summary = script:New-ValidationSummary
1633 $result = script:New-FileValidationResult -FilePath 'test.md'
1634 $result.HasFrontmatter = $true
1635 $summary.AddResult($result)
1636
1637 $output = Write-CIAnnotations -Summary $summary
1638 $output | Should -BeNullOrEmpty
1639 }
1640
1641 It 'Escapes percent character in message' {
1642 $summary = script:New-ValidationSummary
1643 $result = script:New-FileValidationResult -FilePath 'test.md'
1644 $result.AddError('50% complete', 'field')
1645 $summary.AddResult($result)
1646
1647 $output = Write-CIAnnotations -Summary $summary
1648 $output | Should -Match '50%25 complete'
1649 }
1650
1651 It 'Escapes carriage return in message' {
1652 $summary = script:New-ValidationSummary
1653 $result = script:New-FileValidationResult -FilePath 'test.md'
1654 $result.AddError("line1`rline2", 'field')
1655 $summary.AddResult($result)
1656
1657 $output = Write-CIAnnotations -Summary $summary
1658 $output | Should -Match 'line1%0Dline2'
1659 }
1660
1661 It 'Escapes newline in message' {
1662 $summary = script:New-ValidationSummary
1663 $result = script:New-FileValidationResult -FilePath 'test.md'
1664 $result.AddError("line1`nline2", 'field')
1665 $summary.AddResult($result)
1666
1667 $output = Write-CIAnnotations -Summary $summary
1668 $output | Should -Match 'line1%0Aline2'
1669 }
1670
1671 It 'Escapes double colon in message' {
1672 $summary = script:New-ValidationSummary
1673 $result = script:New-FileValidationResult -FilePath 'test.md'
1674 $result.AddError('scope::value', 'field')
1675 $summary.AddResult($result)
1676
1677 $output = Write-CIAnnotations -Summary $summary
1678 $output | Should -Match 'scope%3A%3Avalue'
1679 }
1680
1681 It 'Escapes colon in file path' {
1682 $summary = script:New-ValidationSummary
1683 $result = script:New-FileValidationResult -FilePath 'path:file.md'
1684 $result.AddError('Test error', 'field')
1685 $summary.AddResult($result)
1686
1687 $output = Write-CIAnnotations -Summary $summary
1688 $output | Should -Match 'file=path%3Afile\.md'
1689 }
1690
1691 It 'Escapes comma in file path' {
1692 $summary = script:New-ValidationSummary
1693 $result = script:New-FileValidationResult -FilePath 'file,backup.md'
1694 $result.AddError('Test error', 'field')
1695 $summary.AddResult($result)
1696
1697 $output = Write-CIAnnotations -Summary $summary
1698 $output | Should -Match 'file=file%2Cbackup\.md'
1699 }
1700
1701 It 'Escapes percent in file path' {
1702 $summary = script:New-ValidationSummary
1703 $result = script:New-FileValidationResult -FilePath 'file%20name.md'
1704 $result.AddError('Test error', 'field')
1705 $summary.AddResult($result)
1706
1707 $output = Write-CIAnnotations -Summary $summary
1708 $output | Should -Match 'file=file%2520name\.md'
1709 }
1710
1711 It 'Handles null message gracefully' {
1712 $summary = script:New-ValidationSummary
1713 $result = script:New-FileValidationResult -FilePath 'test.md'
1714 # Create issue with null message via direct class instantiation
1715 $issue = & (Get-Module FrontmatterValidation) {
1716 param($fp)
1717 $i = [ValidationIssue]::new()
1718 $i.Type = 'Error'
1719 $i.Message = $null
1720 $i.FilePath = $fp
1721 $i
1722 } 'test.md'
1723 $result.Issues.Add($issue)
1724 $summary.AddResult($result)
1725
1726 { Write-CIAnnotations -Summary $summary } | Should -Not -Throw
1727 }
1728}
1729
1730Describe 'Export-ValidationResults' -Tag 'Unit' {
1731 It 'Exports valid JSON' {
1732 $summary = script:New-ValidationSummary
1733 $result = script:New-FileValidationResult -FilePath 'test.md'
1734 $summary.AddResult($result)
1735 $summary.Complete()
1736
1737 $outputPath = Join-Path $TestDrive 'test-output.json'
1738 Export-ValidationResults -Summary $summary -OutputPath $outputPath
1739
1740 Test-Path $outputPath | Should -BeTrue
1741 $json = Get-Content $outputPath -Raw | ConvertFrom-Json
1742 $json.totalFiles | Should -Be 1
1743 }
1744
1745 It 'Creates output directory if needed' {
1746 $summary = script:New-ValidationSummary
1747 $summary.Complete()
1748
1749 $outputPath = Join-Path $TestDrive 'subdir/test-output.json'
1750 Export-ValidationResults -Summary $summary -OutputPath $outputPath
1751
1752 Test-Path $outputPath | Should -BeTrue
1753 }
1754
1755 It 'Includes all summary fields in JSON' {
1756 $summary = script:New-ValidationSummary
1757 $result = script:New-FileValidationResult -FilePath 'test.md'
1758 $result.AddError('Test error', 'field')
1759 $result.AddWarning('Test warning', 'field')
1760 $summary.AddResult($result)
1761 $summary.Complete()
1762
1763 $outputPath = Join-Path $TestDrive 'full-output.json'
1764 Export-ValidationResults -Summary $summary -OutputPath $outputPath
1765
1766 $json = Get-Content $outputPath -Raw | ConvertFrom-Json
1767 $json.totalFiles | Should -Be 1
1768 $json.totalErrors | Should -Be 1
1769 $json.totalWarnings | Should -Be 1
1770 $json.filesWithErrors | Should -Be 1
1771 $json.filesWithWarnings | Should -Be 1
1772 }
1773}
1774
1775#endregion
1776#endregion
1777