tests/Report.Pptx.Tests.ps1

#Requires -Version 7.0
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' }

<#
    Pester tests for src/report/renderers/Export-Pptx.ps1 -- the OpenXML-SDK
    executive-deck renderer (ADO Story AB#5048, Epic AB#5044).

    Focus: the empty/near-empty-input crash class found in a StrictMode sweep
    (no prior Pester coverage existed for this renderer). New-ScoutAreaTableSlides/
    New-ScoutGapsSlides/New-ScoutManualSlide and Export-Pptx's own slide-count
    plan each did `$x = @(...) | Sort-Object|Select-Object ...` WITHOUT an outer
    @() wrap around the whole pipeline -- a Sort-Object/Select-Object over zero
    input collapses the bare assignment to $null, and $null.Count throws
    PropertyNotFoundException under Set-StrictMode -Version Latest (this file's
    own Set-StrictMode -Version Latest applies). A scored Findings object with
    zero Areas/Gaps/Manual items -- e.g. a fresh assessment run with nothing
    collected yet, or every rule matched Pass -- hit this on every single run,
    not an obscure edge case.
#>


BeforeAll {
    $script:Root = Split-Path $PSScriptRoot -Parent
    . "$script:Root/src/assess/engine/Get-Score.ps1"
    . "$script:Root/src/report/renderers/Export-Pptx.ps1"

    function New-PptxTestFinding {
        param($Id, $Framework, $Area, $Status, $Severity = 'medium', $Weight = 1.0)
        [pscustomobject]@{
            Id = $Id; Title = "$Id title text for the deck"; Framework = $Framework; Area = $Area; Severity = $Severity
            Status = $Status; EvidenceCount = 2; Evidence = @(); Remediation = "Remediate $Id."
            Manual = ($Status -eq 'Manual'); AreaWeight = $Weight
        }
    }

    $script:Findings = @(
        (New-PptxTestFinding 'CAF-NET-01' 'CAF' 'Networking' 'Pass' 'low')
        (New-PptxTestFinding 'CAF-NET-02' 'CAF' 'Networking' 'Fail' 'high')
        (New-PptxTestFinding 'WAF-SEC-01' 'WAF' 'Security' 'Fail' $null)
        (New-PptxTestFinding 'WAF-SEC-02' 'WAF' 'Security' 'Manual')
    )
    $script:Scored = Get-Score -Findings $script:Findings

    $script:Collect = [pscustomobject]@{
        _meta = [pscustomobject]@{ scope = 'ArmOnly'; managementGroupId = 'mg-test-01' }
    }

    function Get-PptxSlideEntries {
        # Returns via the unary-comma idiom (`return , @(...)`) so a zero- or
        # one-slide result reaches the caller as a real array rather than
        # collapsing to $null/a bare scalar -- callers must NOT re-wrap the call
        # itself in an outer @(...): @(a-function-that-returns-,@(...)) counts
        # the SINGLE returned array object as one pipeline item, double-wrapping
        # it into a one-element array-of-an-array. Capture directly instead
        # (`$slides = Get-PptxSlideEntries ...`), then @() that variable if
        # still-defensive re-wrapping is wanted.
        param([string]$Path)
        Add-Type -AssemblyName System.IO.Compression -ErrorAction SilentlyContinue
        $bytes = [System.IO.File]::ReadAllBytes($Path)
        $ms = [System.IO.MemoryStream]::new($bytes)
        $zip = [System.IO.Compression.ZipArchive]::new($ms, [System.IO.Compression.ZipArchiveMode]::Read)
        try {
            return , @($zip.Entries | Select-Object -ExpandProperty FullName | Where-Object { $_ -match '^ppt/slides/slide\d+\.xml$' })
        }
        finally {
            $zip.Dispose()
            $ms.Dispose()
        }
    }
}

Describe 'Export-Pptx -- basic deck generation AB#5048' {
    BeforeAll {
        $script:OutDir = Join-Path $script:Root 'tests' 'test-output' 'pptx'
        if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force }
        $script:DeckPath = Export-Pptx -Findings $script:Scored -Collect $script:Collect -OutputPath $script:OutDir
    }
    AfterAll {
        if (Test-Path $script:OutDir) { Remove-Item $script:OutDir -Recurse -Force -ErrorAction SilentlyContinue }
    }

    It 'writes assessment_deck.pptx into -OutputPath and returns its path' {
        $script:DeckPath | Should -Exist
        (Split-Path $script:DeckPath -Leaf) | Should -Be 'assessment_deck.pptx'
    }

    It 'produces at least the minimum expected slide count (title/summary/area/gaps/manual/next-steps)' {
        $slides = Get-PptxSlideEntries -Path $script:DeckPath
        $slides.Count | Should -BeGreaterOrEqual 6
    }
}

Describe 'Export-Pptx -- empty-input crash class (StrictMode sweep)' {
    It 'does not throw when Areas/Gaps/Manual are all empty (fresh/all-Pass assessment)' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pptx-empty'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        try {
            $emptyScored = Get-Score -Findings @()
            $emptyScored.Areas   | Should -BeNullOrEmpty
            $emptyScored.Gaps    | Should -BeNullOrEmpty
            $emptyScored.Manual  | Should -BeNullOrEmpty

            # Called directly (not wrapped in `{ } | Should -Not -Throw`) -- that
            # wrapper invokes the scriptblock in a child scope, so a `$path =`
            # assignment inside it never leaks back out to this variable; a throw
            # here still fails the It block on its own, so coverage is unchanged.
            $path = Export-Pptx -Findings $emptyScored -Collect $null -OutputPath $dir
            $path | Should -Exist
            (Split-Path $path -Leaf) | Should -Be 'assessment_deck.pptx'
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }

    It 'does not throw when Areas/Gaps/Manual each carry exactly one item (single-item pipeline collapse)' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pptx-single'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        try {
            $oneFinding = @(New-PptxTestFinding 'CAF-SOLO-01' 'CAF' 'Solo' 'Fail' 'high')
            $singleScored = Get-Score -Findings $oneFinding

            $path = Export-Pptx -Findings $singleScored -Collect $null -OutputPath $dir
            $path | Should -Exist
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }

    It 'New-ScoutAreaTableSlides does not throw and no-ops on an empty -Areas array directly' {
        $pageRef = [ref] 2
        { New-ScoutAreaTableSlides -Shell ([pscustomobject]@{}) -Areas @() -PageCounter $pageRef -TotalPages 6 } | Should -Not -Throw
    }

    It 'New-ScoutGapsSlides does not throw on an empty -Gaps array directly' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pptx-gaps-unit'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        try {
            $shell = New-ScoutDeckShell -OutFile (Join-Path $dir 'unit.pptx')
            $pageRef = [ref] 3
            New-ScoutGapsSlides -Shell $shell -Gaps @() -PageCounter $pageRef -TotalPages 6
            $shell.Doc.Dispose()
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }

    It 'New-ScoutManualSlide does not throw on an empty -Manual array directly' {
        $dir = Join-Path $script:Root 'tests' 'test-output' 'pptx-manual-unit'
        if (Test-Path $dir) { Remove-Item $dir -Recurse -Force }
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
        try {
            $shell = New-ScoutDeckShell -OutFile (Join-Path $dir 'unit.pptx')
            $pageRef = [ref] 4
            New-ScoutManualSlide -Shell $shell -Manual @() -PageCounter $pageRef -TotalPages 6
            $shell.Doc.Dispose()
        }
        finally {
            if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue }
        }
    }
}