Private/TestCaseManagement/Save-TcmTestCaseYaml.ps1

function Save-TcmTestCaseYaml {
    <#
        .SYNOPSIS
            Save a test case object to YAML ensuring 'steps' is the last property inside testCase
            and each step's keys are rendered in the explicit order: stepNumber, attachments, action, expectedResult.

        .PARAMETER FilePath
            Path to the YAML file to write.

        .PARAMETER Data
            The full test case object (expects at least testCase, optionally history).

        .PARAMETER TestCasesRoot
            Root directory for test cases. Used for relative path calculations when creating folder structure.
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $FilePath,

        [Parameter(Mandatory)]
        $Data,

        [string] $TestCasesRoot
    )

    try {
        # Handle folder structure creation - always enabled
        if ($Data.testCase -and $Data.testCase.areaPath) {
            $areaPath = $Data.testCase.areaPath

            # Get folder path from area path
            $folderPath = Get-TcmFolderPathFromAreaPath -AreaPath $areaPath

            if (-not [string]::IsNullOrEmpty($folderPath)) {
                # If TestCasesRoot is provided, make FilePath relative to it first
                if ($TestCasesRoot) {
                    $FilePath = [System.IO.Path]::GetRelativePath($TestCasesRoot, $FilePath)
                }

                # Combine folder path with existing file path
                $folderPath = $folderPath.TrimEnd('/')
                $fileName = [System.IO.Path]::GetFileName($FilePath)
                $FilePath = Join-Path $folderPath $fileName

                # If TestCasesRoot was provided, make it absolute again
                if ($TestCasesRoot) {
                    $FilePath = Join-Path $TestCasesRoot $FilePath
                }
            }
        }

        # deep copy
        $copy = $Data | ConvertTo-Json -Depth 20 | ConvertFrom-Json

        # Drop legacy metadata block from the serialized output while still
        # allowing callers to pass it for backward compatibility.
        if ($copy.PSObject.Properties.Name -contains 'metadata') {
            $copy.PSObject.Properties.Remove('metadata') | Out-Null
        }

        if ($copy.PSObject.Properties.Name -contains 'testCase') {
            $tc = $copy.testCase

            # Build ordered testCase: ensure specific important fields appear first in requested order
            $orderedTc = [ordered]@{}

            # Preferred ordering at the top of testCase
            $preferred = @('id', 'title', 'areaPath', 'iterationPath', 'tags', 'assignedTo', 'description')
            foreach ($key in $preferred) {
                if ($tc.PSObject.Properties.Name -contains $key) {
                    $orderedTc[$key] = $tc.$key
                }
            }

            # Add remaining properties (except steps) in their existing order if not already added
            foreach ($p in $tc.PSObject.Properties) {
                if ($p.Name -ne 'steps' -and -not ($orderedTc.Contains($p.Name))) {
                    $orderedTc[$p.Name] = $p.Value
                }
            }

            $stepsArray = @()
            if ($tc.steps) {
                foreach ($s in $tc.steps) {
                    $os = [ordered] @{
                        stepNumber     = $s.stepNumber
                        attachments    = $s.attachments
                        action         = $s.action
                        expectedResult = $s.expectedResult
                    }
                    $stepsArray += $os
                }
            }

            $orderedTc['steps'] = $stepsArray
            $copy.testCase = $orderedTc

        }

        $yaml = ConvertTo-Yaml $copy

        # Manual rendering of steps block to guarantee key order
        $lines = $yaml -split "\r?\n"
        $stepsIdx = -1
        for ($i = 0; $i -lt $lines.Count; $i++) {
            if ($lines[$i] -match '^(\s*)steps:\s*$') {
                $found = $false
                for ($k = $i - 1; $k -ge [Math]::Max(0, $i - 8); $k--) { if ($lines[$k] -match '^\s*testCase:\s*$') { $found = $true; break } }
                if ($found) { $stepsIdx = $i; break }
            }
        }

        if ($stepsIdx -ge 0) {
            $indent = ([regex]::Match($lines[$stepsIdx], '^(\s*)')).Groups[1].Value
            $end = $stepsIdx + 1
            while ($end -lt $lines.Count) {
                $lineIndent = ([regex]::Match($lines[$end], '^(\s*)')).Groups[1].Value
                if ($lineIndent.Length -le $indent.Length -and $lines[$end].Trim() -ne '') { break }
                $end++
            }

            $out = New-Object System.Collections.Generic.List[string]
            $out.Add("$indent`steps:")
            foreach ($s in $copy.testCase.steps) {
                $out.Add($indent + '- ' + "stepNumber: $($s.stepNumber)")
                $out.Add($indent + ' attachments: ' + (if ($s.attachments -and $s.attachments.Count -gt 0) { '[]' } else { '[]' }))
                $actionText = if ([string]::IsNullOrEmpty($s.action)) { '""' } else { '"' + ($s.action -replace '"', '""') + '"' }
                $expText = if ([string]::IsNullOrEmpty($s.expectedResult)) { '""' } else { '"' + ($s.expectedResult -replace '"', '""') + '"' }
                $out.Add($indent + ' action: ' + $actionText)
                $out.Add($indent + ' expectedResult: ' + $expText)
            }

            $new = @()
            if ($stepsIdx -gt 0) { $new += $lines[0..($stepsIdx - 1)] }
            $new += $out
            if ($end -lt $lines.Count) { $new += $lines[$end..($lines.Count - 1)] }

            $yaml = ($new -join "`n")
        }

        # Ensure output directory exists
        $outputDir = Split-Path -Parent $FilePath
        if (-not (Test-Path $outputDir)) {
            New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
        }

        Set-Content -Path $FilePath -Value $yaml -Encoding UTF8

        return $FilePath
    } catch {
        throw "Failed to save YAML to '$FilePath': $($_.Exception.Message)"
    }
}