Skip to content

Commit 86efeea

Browse files
committed
feat(#201): mirror scaffold and update helper tests
Add source-mirrored, dot-source-first Pester test files for the small and well-isolated helpers under src/private/scaffold and src/private/update so each unit-level helper has a focused test file under tests/private/scaffold or tests/private/update instead of being covered only through broad legacy coverage files. The remaining workflow-level scaffold and update helpers stay covered by the existing broad tests for now.
1 parent c7001bf commit 86efeea

26 files changed

Lines changed: 901 additions & 0 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
2222
- `Initialize-NovaModule` and `% nova init` now check for newer NovaModuleTools releases before the first interactive scaffold question and reuse the same non-blocking update warning behavior already used by build.
2323
- `Test-NovaBuild` and `% nova test` now enforce `Pester.CodeCoverage.CoveragePercentTarget` from `project.json` when `CodeCoverage.Enabled` is `true`, failing the test run when measured coverage drops below the configured target.
2424
- Added a non-blocking `scripts/build/Get-TestMirrorStatus.ps1` helper that reports `src/**/*.ps1` files without a matching mirrored test file under `tests/`, to support the migration to the source-mirrored, dot-source-first test layout.
25+
- Added mirrored, dot-source-first Pester test files for scaffold and update private helpers (`src/private/scaffold/*.ps1` and `src/private/update/*.ps1`), so each unit-level helper now has a focused test file under `tests/private/scaffold/` or `tests/private/update/` instead of being covered only through broad legacy coverage files.
2526

2627

2728
### Changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/AssertNovaModuleQuestionAnswerValid.ps1')
4+
5+
function Get-AwesomePromptValidationFailure {param($Ask, $Value)}
6+
function Stop-NovaOperation {
7+
param([string]$Message, [string]$ErrorId, $Category, $TargetObject)
8+
throw $Message
9+
}
10+
}
11+
12+
Describe 'Assert-NovaModuleQuestionAnswerValid' {
13+
BeforeAll {
14+
$script:question = [pscustomobject]@{Name = 'ProjectName'}
15+
}
16+
17+
It 'returns silently when the validator reports no failure' {
18+
Mock Get-AwesomePromptValidationFailure {return $null}
19+
Mock Stop-NovaOperation {throw 'should not be called'}
20+
21+
{Assert-NovaModuleQuestionAnswerValid -Question $script:question -Value 'NovaThing'} | Should -Not -Throw
22+
Assert-MockCalled Stop-NovaOperation -Times 0
23+
}
24+
25+
It 'stops the operation when the validator reports a failure' {
26+
Mock Get-AwesomePromptValidationFailure {
27+
return [pscustomobject]@{
28+
Message = 'invalid name'
29+
ErrorId = 'Nova.Validation.ProjectName'
30+
Category = 'InvalidArgument'
31+
TargetObject = $Value
32+
}
33+
}
34+
Mock Stop-NovaOperation {throw "$Message ($ErrorId)"}
35+
36+
{Assert-NovaModuleQuestionAnswerValid -Question $script:question -Value 'bad name'} | Should -Throw '*invalid name*'
37+
Assert-MockCalled Stop-NovaOperation -Times 1 -ParameterFilter {
38+
$Message -eq 'invalid name' -and $ErrorId -eq 'Nova.Validation.ProjectName'
39+
}
40+
}
41+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/CopyNovaExampleProjectTemplate.ps1')
4+
5+
function Get-NovaModuleProjectTemplatePath {param([switch]$Example)}
6+
}
7+
8+
Describe 'Copy-NovaExampleProjectTemplate' {
9+
BeforeAll {
10+
$script:templateRoot = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().Guid))
11+
$null = New-Item -ItemType Directory -Path (Join-Path $script:templateRoot 'src')
12+
Set-Content -LiteralPath (Join-Path $script:templateRoot 'project.json') -Value '{}' -NoNewline
13+
Set-Content -LiteralPath (Join-Path $script:templateRoot 'src/example.ps1') -Value '# example' -NoNewline
14+
15+
$script:destination = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().Guid))
16+
}
17+
18+
AfterAll {
19+
Remove-Item -LiteralPath $script:templateRoot -Recurse -Force -ErrorAction SilentlyContinue
20+
Remove-Item -LiteralPath $script:destination -Recurse -Force -ErrorAction SilentlyContinue
21+
}
22+
23+
It 'copies every template item under the resolved template root into the destination' {
24+
Mock Get-NovaModuleProjectTemplatePath {return (Join-Path $script:templateRoot 'project.json')}
25+
26+
Copy-NovaExampleProjectTemplate -DestinationPath $script:destination.FullName
27+
28+
Test-Path -LiteralPath (Join-Path $script:destination 'project.json') | Should -BeTrue
29+
Test-Path -LiteralPath (Join-Path $script:destination 'src/example.ps1') | Should -BeTrue
30+
Assert-MockCalled Get-NovaModuleProjectTemplatePath -Times 1 -ParameterFilter {$Example.IsPresent}
31+
}
32+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/GetNovaModuleAgenticCopilotTemplateRoot.ps1')
4+
5+
function Get-ResourceFilePath {param([string]$FileName)}
6+
}
7+
8+
Describe 'Get-NovaModuleAgenticCopilotTemplateRoot' {
9+
It 'returns the directory that owns the agentic-copilot sentinel file' {
10+
Mock Get-ResourceFilePath {return '/resources/agentic-copilot/AGENTS.md'}
11+
12+
$root = Get-NovaModuleAgenticCopilotTemplateRoot
13+
14+
$root | Should -Be (Split-Path -Parent '/resources/agentic-copilot/AGENTS.md')
15+
Assert-MockCalled Get-ResourceFilePath -Times 1 -ParameterFilter {
16+
$FileName -like '*agentic-copilot*AGENTS.md'
17+
}
18+
}
19+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/GetNovaModuleProjectTemplatePath.ps1')
4+
5+
function Get-ResourceFilePath {param([string]$FileName) return "/resources/$FileName"}
6+
}
7+
8+
Describe 'Get-NovaModuleProjectTemplatePath' {
9+
It 'resolves the standard template path through Get-ResourceFilePath' {
10+
Mock Get-ResourceFilePath {return "/resources/$FileName"}
11+
12+
$path = Get-NovaModuleProjectTemplatePath
13+
14+
$path | Should -Be '/resources/ProjectTemplate.json'
15+
Assert-MockCalled Get-ResourceFilePath -Times 1 -ParameterFilter {
16+
$FileName -eq 'ProjectTemplate.json'
17+
}
18+
}
19+
20+
It 'resolves the example template path under example/project.json when -Example is set' {
21+
Mock Get-ResourceFilePath {return "/resources/$FileName"}
22+
23+
$path = Get-NovaModuleProjectTemplatePath -Example
24+
25+
$path | Should -Match 'example.+project\.json$'
26+
Assert-MockCalled Get-ResourceFilePath -Times 1 -ParameterFilter {
27+
$FileName -like 'example*project.json'
28+
}
29+
}
30+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/GetNovaModuleScaffoldPaths.ps1')
4+
}
5+
6+
Describe 'Get-NovaModuleScaffoldLayout' {
7+
It 'composes scaffold paths under the provided base path and project name' {
8+
$layout = Get-NovaModuleScaffoldLayout -Path '/tmp/projects' -ProjectName 'DemoModule'
9+
10+
$layout.Project | Should -Be (Join-Path '/tmp/projects' 'DemoModule')
11+
$layout.Src | Should -Be (Join-Path $layout.Project 'src')
12+
$layout.Private | Should -Be (Join-Path $layout.Src 'private')
13+
$layout.Public | Should -Be (Join-Path $layout.Src 'public')
14+
$layout.Resources | Should -Be (Join-Path $layout.Src 'resources')
15+
$layout.Classes | Should -Be (Join-Path $layout.Src 'classes')
16+
$layout.Tests | Should -Be (Join-Path $layout.Project 'tests')
17+
$layout.ProjectJsonFile | Should -Be (Join-Path $layout.Project 'project.json')
18+
}
19+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/InvokeNovaModuleInitializationWorkflow.ps1')
4+
5+
function Initialize-NovaModuleScaffold {param($Answer, $Paths, [switch]$Example)}
6+
function Write-NovaModuleProjectJson {param($Answer, [string]$ProjectJsonFile, [switch]$Example)}
7+
function Initialize-NovaModuleAgenticCopilotScaffold {param($Answer, [string]$ProjectRoot, [switch]$Example)}
8+
function Write-Message {param([Parameter(ValueFromPipeline = $true)]$InputObject, [string]$color)}
9+
}
10+
11+
Describe 'Invoke-NovaModuleInitializationWorkflow' {
12+
BeforeAll {
13+
$script:context = [pscustomobject]@{
14+
AnswerSet = @{ProjectName = 'DemoModule'; EnableAgenticCopilot = 'No'}
15+
Layout = [pscustomobject]@{Project = '/tmp/DemoModule'; ProjectJsonFile = '/tmp/DemoModule/project.json'}
16+
Example = $false
17+
}
18+
}
19+
20+
It 'initializes the scaffold and writes the project.json before announcing completion' {
21+
Mock Initialize-NovaModuleScaffold {}
22+
Mock Write-NovaModuleProjectJson {}
23+
Mock Initialize-NovaModuleAgenticCopilotScaffold {}
24+
Mock Write-Message {}
25+
26+
Invoke-NovaModuleInitializationWorkflow -WorkflowContext $script:context
27+
28+
Assert-MockCalled Initialize-NovaModuleScaffold -Times 1
29+
Assert-MockCalled Write-NovaModuleProjectJson -Times 1
30+
Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 0
31+
Assert-MockCalled Write-Message -Times 1
32+
}
33+
34+
It 'invokes the Agentic Copilot scaffold step when the answer set requests it' {
35+
Mock Initialize-NovaModuleScaffold {}
36+
Mock Write-NovaModuleProjectJson {}
37+
Mock Initialize-NovaModuleAgenticCopilotScaffold {}
38+
Mock Write-Message {}
39+
40+
$contextWithAgentic = [pscustomobject]@{
41+
AnswerSet = @{ProjectName = 'DemoModule'; EnableAgenticCopilot = 'Yes'}
42+
Layout = [pscustomobject]@{Project = '/tmp/DemoModule'; ProjectJsonFile = '/tmp/DemoModule/project.json'}
43+
Example = $false
44+
}
45+
Invoke-NovaModuleInitializationWorkflow -WorkflowContext $contextWithAgentic
46+
47+
Assert-MockCalled Initialize-NovaModuleAgenticCopilotScaffold -Times 1
48+
}
49+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/scaffold/ResolveNovaModuleScaffoldBasePath.ps1')
4+
5+
function Stop-NovaOperation {
6+
param([string]$Message, [string]$ErrorId, $Category, $TargetObject)
7+
throw $Message
8+
}
9+
}
10+
11+
Describe 'Resolve-NovaModuleScaffoldBasePath' {
12+
BeforeAll {
13+
$script:tempDir = (Resolve-Path -LiteralPath (New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().Guid)))).Path
14+
}
15+
16+
AfterAll {
17+
Remove-Item -LiteralPath $script:tempDir -Recurse -Force -ErrorAction SilentlyContinue
18+
}
19+
20+
It 'returns the full resolved path when the directory exists' {
21+
$result = Resolve-NovaModuleScaffoldBasePath -Path $script:tempDir
22+
$result | Should -Be ([System.IO.Path]::GetFullPath($script:tempDir))
23+
}
24+
25+
It 'normalizes mixed separators before resolving' {
26+
$mixed = $script:tempDir -replace '/', '\'
27+
$result = Resolve-NovaModuleScaffoldBasePath -Path $mixed
28+
$result | Should -Be ([System.IO.Path]::GetFullPath($script:tempDir))
29+
}
30+
31+
It 'stops the operation when the path does not exist' {
32+
$missing = Join-Path $script:tempDir ('missing-' + [guid]::NewGuid().Guid)
33+
{Resolve-NovaModuleScaffoldBasePath -Path $missing} | Should -Throw '*Not a valid path*'
34+
}
35+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/update/ConfirmNovaPrereleaseModuleUpdate.ps1')
4+
5+
function Get-NovaPrereleaseModuleUpdateConfirmationPrompt {
6+
param([string]$CurrentVersion, [string]$TargetVersion)
7+
return [pscustomobject]@{Caption = 'caption'; Message = 'message'; Choice = [ordered]@{Y='Yes';N='No'}; Default = 'N'}
8+
}
9+
10+
function Read-AwesomeChoicePrompt {
11+
param($Ask, $HostUi)
12+
return 'Y'
13+
}
14+
}
15+
16+
Describe 'Confirm-NovaPrereleaseModuleUpdate' {
17+
BeforeAll {
18+
$script:fakeCmdlet = [pscustomobject]@{Host = [pscustomobject]@{UI = [pscustomobject]@{Name = 'stub'}}}
19+
}
20+
21+
It 'returns true when the user selects Yes' {
22+
Mock Read-AwesomeChoicePrompt {return 'Y'}
23+
Confirm-NovaPrereleaseModuleUpdate -Cmdlet $script:fakeCmdlet -CurrentVersion '1.0.0' -TargetVersion '2.0.0-beta1' | Should -BeTrue
24+
}
25+
26+
It 'returns false when the user selects No' {
27+
Mock Read-AwesomeChoicePrompt {return 'N'}
28+
Confirm-NovaPrereleaseModuleUpdate -Cmdlet $script:fakeCmdlet -CurrentVersion '1.0.0' -TargetVersion '2.0.0-beta1' | Should -BeFalse
29+
}
30+
31+
It 'builds the prompt from the current and target version inputs' {
32+
Mock Get-NovaPrereleaseModuleUpdateConfirmationPrompt {return [pscustomobject]@{Caption = 'caption'; Message = 'message'; Choice = [ordered]@{Y='Yes';N='No'}; Default = 'N'}}
33+
Mock Read-AwesomeChoicePrompt {return 'N'}
34+
35+
Confirm-NovaPrereleaseModuleUpdate -Cmdlet $script:fakeCmdlet -CurrentVersion '1.0.0' -TargetVersion '2.0.0-beta1' | Out-Null
36+
37+
Assert-MockCalled Get-NovaPrereleaseModuleUpdateConfirmationPrompt -Times 1 -ParameterFilter {
38+
$CurrentVersion -eq '1.0.0' -and $TargetVersion -eq '2.0.0-beta1'
39+
}
40+
}
41+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
BeforeAll {
2+
$projectRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
3+
. (Join-Path $projectRoot 'src/private/update/ConvertToNovaModuleSelfUpdatePlan.ps1')
4+
}
5+
6+
Describe 'Get-NovaModuleSelfUpdateLookupCandidate' {
7+
It 'returns null when the lookup result is null' {
8+
Get-NovaModuleSelfUpdateLookupCandidate -LookupResult $null -PrereleaseNotificationsEnabled $true | Should -BeNullOrEmpty
9+
}
10+
11+
It 'prefers the prerelease candidate when prerelease notifications are enabled' {
12+
$lookup = [pscustomobject]@{
13+
Prerelease = [pscustomobject]@{Version = '2.0.0-beta1'}
14+
Stable = [pscustomobject]@{Version = '1.5.0'}
15+
}
16+
(Get-NovaModuleSelfUpdateLookupCandidate -LookupResult $lookup -PrereleaseNotificationsEnabled $true).Version | Should -Be '2.0.0-beta1'
17+
}
18+
19+
It 'prefers the stable candidate when prerelease notifications are disabled' {
20+
$lookup = [pscustomobject]@{
21+
Prerelease = [pscustomobject]@{Version = '2.0.0-beta1'}
22+
Stable = [pscustomobject]@{Version = '1.5.0'}
23+
}
24+
(Get-NovaModuleSelfUpdateLookupCandidate -LookupResult $lookup -PrereleaseNotificationsEnabled $false).Version | Should -Be '1.5.0'
25+
}
26+
27+
It 'falls back to the prerelease candidate when no stable candidate exists and prerelease notifications are disabled' {
28+
$lookup = [pscustomobject]@{
29+
Prerelease = [pscustomobject]@{Version = '2.0.0-beta1'}
30+
Stable = $null
31+
}
32+
(Get-NovaModuleSelfUpdateLookupCandidate -LookupResult $lookup -PrereleaseNotificationsEnabled $false).Version | Should -Be '2.0.0-beta1'
33+
}
34+
}
35+
36+
Describe 'Get-NovaModuleSelfUpdateLookupRepository' {
37+
It 'returns the candidate repository when the candidate carries one' {
38+
$candidate = [pscustomobject]@{Repository = 'CandidateRepo'}
39+
$lookup = [pscustomobject]@{SourceRepository = 'LookupRepo'}
40+
Get-NovaModuleSelfUpdateLookupRepository -LookupResult $lookup -LookupCandidate $candidate | Should -Be 'CandidateRepo'
41+
}
42+
43+
It 'falls back to the lookup result SourceRepository when the candidate lacks Repository' {
44+
$candidate = [pscustomobject]@{Version = '1.0.0'}
45+
$lookup = [pscustomobject]@{SourceRepository = 'LookupRepo'}
46+
Get-NovaModuleSelfUpdateLookupRepository -LookupResult $lookup -LookupCandidate $candidate | Should -Be 'LookupRepo'
47+
}
48+
49+
It 'returns null when neither the candidate nor the lookup result carries a repository' {
50+
$candidate = [pscustomobject]@{Version = '1.0.0'}
51+
$lookup = [pscustomobject]@{Version = '1.0.0'}
52+
Get-NovaModuleSelfUpdateLookupRepository -LookupResult $lookup -LookupCandidate $candidate | Should -BeNullOrEmpty
53+
}
54+
}
55+
56+
Describe 'Get-NovaModuleSelfUpdatePlanContext' {
57+
It 'returns empty lookup fields when no lookup result is available' {
58+
$context = Get-NovaModuleSelfUpdatePlanContext -LookupResult $null -PrereleaseNotificationsEnabled $true
59+
$context.LookupCandidateVersion | Should -BeNullOrEmpty
60+
$context.LookupCandidateChannel | Should -BeNullOrEmpty
61+
$context.LookupRepository | Should -BeNullOrEmpty
62+
$context.PrereleaseNotificationsEnabled | Should -BeTrue
63+
}
64+
65+
It 'populates lookup candidate metadata when the lookup result has a stable candidate' {
66+
$lookup = [pscustomobject]@{
67+
Prerelease = $null
68+
Stable = [pscustomobject]@{Version = '1.5.0'; Channel = 'Stable'; Repository = 'PSGallery'}
69+
SourceRepository = 'PSGallery'
70+
}
71+
$context = Get-NovaModuleSelfUpdatePlanContext -LookupResult $lookup -PrereleaseNotificationsEnabled $false
72+
73+
$context.LookupCandidateVersion | Should -Be '1.5.0'
74+
$context.LookupCandidateChannel | Should -Be 'Stable'
75+
$context.LookupRepository | Should -Be 'PSGallery'
76+
}
77+
}
78+
79+
Describe 'ConvertTo-NovaModuleSelfUpdatePlan' {
80+
BeforeAll {
81+
$script:installed = [pscustomobject]@{ModuleName = 'NovaModuleTools'; Version = '1.0.0'}
82+
$script:context = [pscustomobject]@{
83+
LookupCandidateVersion = '2.0.0-beta1'
84+
LookupCandidateChannel = 'Prerelease'
85+
LookupRepository = 'PSGallery'
86+
PrereleaseNotificationsEnabled = $true
87+
}
88+
}
89+
90+
It 'returns a no-update plan when no target version is provided' {
91+
$plan = ConvertTo-NovaModuleSelfUpdatePlan -InstalledModule $script:installed -PlanContext $script:context
92+
93+
$plan.TargetVersion | Should -BeNullOrEmpty
94+
$plan.UpdateAvailable | Should -BeFalse
95+
$plan.IsPrereleaseTarget | Should -BeFalse
96+
$plan.UsedAllowPrerelease | Should -BeFalse
97+
}
98+
99+
It 'marks the plan as an available update when a target version is provided' {
100+
$plan = ConvertTo-NovaModuleSelfUpdatePlan -InstalledModule $script:installed -PlanContext $script:context -TargetVersion '2.0.0'
101+
102+
$plan.TargetVersion | Should -Be '2.0.0'
103+
$plan.UpdateAvailable | Should -BeTrue
104+
$plan.IsPrereleaseTarget | Should -BeFalse
105+
}
106+
107+
It 'flags prerelease target plans through IsPrereleaseTarget and UsedAllowPrerelease' {
108+
$plan = ConvertTo-NovaModuleSelfUpdatePlan -InstalledModule $script:installed -PlanContext $script:context -TargetVersion '2.0.0-beta1' -PrereleaseTarget
109+
110+
$plan.IsPrereleaseTarget | Should -BeTrue
111+
$plan.UsedAllowPrerelease | Should -BeTrue
112+
}
113+
}

0 commit comments

Comments
 (0)