_TESTS/PSLocalBase.Property.Tests.ps1

# Seeded randomized comparison of query results against an independent PowerShell model.
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PSLocalBase.psd1') -Force
$script:passed = 0; $script:failed = 0
$seed = 20261029
$random = [System.Random]::new($seed)

function Check([string]$Name, [scriptblock]$Body) {
    try { & $Body; $script:passed++; Write-Host "[PASS] $Name" }
    catch { $script:failed++; Write-Host "[FAIL] ${Name}: $($_.Exception.Message)" }
}

# Documents: n is an integer, null, or missing; u is unique per collection and drives ordering.
function New-Docs([string]$Prefix, [int]$Count, [int[]]$UniqueValues) {
    $docs = [System.Collections.Generic.List[object]]::new()
    for ($i = 0; $i -lt $Count; $i++) {
        $doc = [ordered]@{ u = $UniqueValues[$i] }
        $roll = $random.Next(10)
        if ($roll -lt 7) { $doc['n'] = $random.Next(0, 20) } elseif ($roll -lt 9) { $doc['n'] = $null }
        $doc['s'] = @('red', 'green', 'blue')[$random.Next(3)]
        $docs.Add([pscustomobject]@{ Key = "$Prefix$i"; Doc = $doc })
    }
    return $docs
}

function Test-Predicate($Doc, [string]$Op, $Value) {
    $has = $Doc.Contains('n') -and $null -ne $Doc['n']
    switch ($Op) {
        '='      { return ($has -and $Doc['n'] -eq $Value) }
        '!='     { return ($has -and $Doc['n'] -ne $Value) }
        '>'      { return ($has -and $Doc['n'] -gt $Value) }
        '<'      { return ($has -and $Doc['n'] -lt $Value) }
        '>='     { return ($has -and $Doc['n'] -ge $Value) }
        '<='     { return ($has -and $Doc['n'] -le $Value) }
        'in'     {
            $hit = $has -and (@($Value | Where-Object { $null -ne $_ }) -contains $Doc['n'])
            return ($hit -or (-not $has -and @($Value | Where-Object { $null -eq $_ }).Count -gt 0))
        }
        'not-in' { return ($has -and -not (@($Value | Where-Object { $null -ne $_ }) -contains $Doc['n'])) }
    }
    throw "Unknown operator $Op"
}

function Get-Page([object[]]$Rows, [object]$Skip, [object]$Limit) {
    $ordered = @($Rows | Sort-Object { $_.Doc['u'] })
    $start = if ($null -ne $Skip) { [int]$Skip } else { 0 }
    if ($start -ge $ordered.Count) { return @() }
    $end = if ($null -ne $Limit) { [Math]::Min($ordered.Count, $start + [int]$Limit) } else { $ordered.Count }
    if ($end -le $start) { return @() }
    return $ordered[$start..($end - 1)]
}

function Format-Keys($Rows) { return (@($Rows | ForEach-Object { $_.Key }) -join ',') }

$db = New-LocalBase
try {
    $uniqueA = @(1..120 | Sort-Object { $random.Next() })
    $uniqueB = @(121..200 | Sort-Object { $random.Next() })
    $docsA = New-Docs 'a' 120 $uniqueA
    $docsB = New-Docs 'b' 80 $uniqueB
    # Shared keys with identical documents exercise UNION de-duplication.
    for ($i = 0; $i -lt 15; $i++) { $docsB[$i] = [pscustomobject]@{ Key = $docsA[$i].Key; Doc = $docsA[$i].Doc } }
    foreach ($d in $docsA) { $db.collection('pa').doc($d.Key).set($d.Doc) }
    foreach ($d in $docsB) { $db.collection('pb').doc($d.Key).set($d.Doc) }
    $ops = @('=', '!=', '>', '<', '>=', '<=', 'in', 'not-in')

    Check "where() operators match the model (seed $seed)" {
        for ($round = 0; $round -lt 80; $round++) {
            $op = $ops[$random.Next($ops.Count)]
            $value = if ($op -in @('in', 'not-in')) {
                $list = @(for ($k = 0; $k -lt $random.Next(0, 5); $k++) { $random.Next(0, 20) })
                if ($random.Next(3) -eq 0) { $list += $null }
                , $list
            } else { $random.Next(0, 20) }
            $expected = @($docsA | Where-Object { Test-Predicate $_.Doc $op $value } | ForEach-Object { $_.Key } | Sort-Object)
            $actual = @($db.collection('pa').where('n', $op, $value).get(@{ keys = $true }) | ForEach-Object { $_.key } | Sort-Object)
            if (($expected -join ',') -cne ($actual -join ',')) { throw "round $round op=$op value=$($value -join '|') expected=$($expected.Count) actual=$($actual.Count)" }
            $count = $db.collection('pa').where('n', $op, $value).count()
            if ($count -ne $expected.Count) { throw "round $round count=$count expected=$($expected.Count)" }
        }
    }

    Check "orderBy/skip/limit pages match the model (seed $seed)" {
        for ($round = 0; $round -lt 40; $round++) {
            $op = $ops[$random.Next(6)]
            $value = $random.Next(0, 20)
            $skip = if ($random.Next(2) -eq 0) { $random.Next(0, 30) } else { $null }
            $limit = if ($random.Next(2) -eq 0) { $random.Next(0, 30) } else { $null }
            $q = $db.collection('pa').where('n', $op, $value).orderBy('u')
            if ($null -ne $skip) { $q = $q.skip($skip) }
            if ($null -ne $limit) { $q = $q.limit($limit) }
            $expected = Get-Page @($docsA | Where-Object { Test-Predicate $_.Doc $op $value }) $skip $limit
            $actual = @($q.get(@{ keys = $true }) | ForEach-Object { [pscustomobject]@{ Key = $_.key } })
            if ((Format-Keys $expected) -cne (Format-Keys $actual)) { throw "round $round op=$op value=$value skip=$skip limit=$limit" }
            if ($q.count() -ne @($expected).Count) { throw "round $round count mismatch" }
            if ($q.exists() -ne (@($expected).Count -gt 0)) { throw "round $round exists mismatch" }
        }
    }

    Check "union/unionAll with branch filters and global paging match the model (seed $seed)" {
        for ($round = 0; $round -lt 40; $round++) {
            $leftValue = $random.Next(0, 20); $rightValue = $random.Next(0, 20); $globalValue = $random.Next(0, 10)
            $all = $random.Next(2) -eq 0
            $skip = $random.Next(0, 20); $limit = $random.Next(1, 25)
            $left = @($docsA | Where-Object { Test-Predicate $_.Doc '>=' $leftValue })
            $right = @($docsB | Where-Object { Test-Predicate $_.Doc '<=' $rightValue })
            $combined = if ($all) { @($left) + @($right) } else {
                $seen = @{}
                @(foreach ($row in (@($left) + @($right))) {
                    $id = $row.Key + '|' + ($row.Doc | ConvertTo-Json -Compress)
                    if (-not $seen.ContainsKey($id)) { $seen[$id] = $true; $row }
                })
            }
            $filtered = @($combined | Where-Object { Test-Predicate $_.Doc '>' $globalValue })
            $expected = Get-Page $filtered $skip $limit
            $lq = $db.collection('pa').where('n', '>=', $leftValue)
            $rq = $db.collection('pb').where('n', '<=', $rightValue)
            $composite = if ($all) { $lq.unionAll($rq) } else { $lq.union($rq) }
            $page = $composite.where('n', '>', $globalValue).orderBy('u').skip($skip).limit($limit)
            $actual = @($page.get(@{ keys = $true }) | ForEach-Object { [pscustomobject]@{ Key = $_.key } })
            if ((Format-Keys $expected) -cne (Format-Keys $actual)) { throw "round $round all=$all left=$leftValue right=$rightValue global=$globalValue skip=$skip limit=$limit" }
            if ($page.count() -ne @($expected).Count) { throw "round $round composite count mismatch" }
        }
    }
} finally { $db.close() }

Write-Host "Property (seed $seed): passed=$script:passed; failed=$script:failed"
if ($script:failed -gt 0) { exit 1 }