microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/update-workflow-file-and-script

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/tests/linting/FrontmatterValidation.Tests.ps1

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