microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v2.0.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/linting/FrontmatterValidation.Tests.ps1

1746lines · modecode

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