microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
chore/update-security-instruction-attributions

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/linting/FrontmatterValidation.Tests.ps1

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