Private/Assert-CloudflareClefQuestions.ps1

# Validate the named question map against Cloudflare Clef's documented input contract.
function Assert-CloudflareClefQuestions {
    <#
    .SYNOPSIS
        Validates named typed questions before a Cloudflare Clef request.
    .DESCRIPTION
        Enforces the documented question count, key syntax, question types,
        instructions, and choice or score criteria shapes and limits.
    .PARAMETER Questions
        The named question dictionary to validate.
    #>

    [CmdletBinding()]
    param (
        # Receive the request's named question map.
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [System.Collections.IDictionary] $Questions
    )

    # Require Cloudflare's documented range of one to 64 questions.
    if ($Questions.Count -lt 1 -or $Questions.Count -gt 64) {
        throw [System.ArgumentException]::new('Questions must contain between 1 and 64 named questions.', 'Questions')
    }

    # Validate each question name and its typed definition.
    foreach ($name in $Questions.Keys) {
        # Convert the key once so validation and error text use the same value.
        $questionName = [string] $name
        if ($questionName.Length -gt 100 -or $questionName -notmatch '^[A-Za-z0-9_.-]+$') {
            throw [System.ArgumentException]::new("Question ID '$questionName' must use only letters, digits, underscore, period, or hyphen and be at most 100 characters.", 'Questions')
        }

        # Require each definition to be a dictionary.
        $question = $Questions[$name]
        if ($question -isnot [System.Collections.IDictionary]) {
            throw [System.ArgumentException]::new("Question '$questionName' must be a dictionary with type and instructions fields.", 'Questions')
        }

        # Accept only the three System One typed question kinds.
        if (-not $question.Contains('type') -or [string] $question.type -notin @('noul', 'choice', 'score')) {
            throw [System.ArgumentException]::new("Question '$questionName' type must be noul, choice, or score.", 'Questions')
        }

        # Require useful instructions for every question type.
        if (-not $question.Contains('instructions') -or [string]::IsNullOrWhiteSpace([string] $question.instructions)) {
            throw [System.ArgumentException]::new("Question '$questionName' must include non-empty instructions.", 'Questions')
        }

        # Validate choice options as a non-empty named dictionary of at most 255 entries.
        if ($question.type -eq 'choice') {
            if (-not $question.Contains('criteria') -or $question.criteria -isnot [System.Collections.IDictionary] -or $question.criteria.Count -lt 1 -or $question.criteria.Count -gt 255) {
                throw [System.ArgumentException]::new("Choice question '$questionName' requires 1 to 255 named criteria options.", 'Questions')
            }
        }

        # Validate score criteria as an ordered list of two to ten rubric levels.
        if ($question.type -eq 'score') {
            if (-not $question.Contains('criteria') -or $question.criteria -isnot [System.Array] -or $question.criteria.Count -lt 2 -or $question.criteria.Count -gt 10) {
                throw [System.ArgumentException]::new("Score question '$questionName' requires an ordered array of 2 to 10 rubric levels.", 'Questions')
            }
        }
    }
}