diff --git a/Tools/windows/Convert-CanvasPaintArtifact.ps1 b/Tools/windows/Convert-CanvasPaintArtifact.ps1 new file mode 100644 index 00000000..54979fda --- /dev/null +++ b/Tools/windows/Convert-CanvasPaintArtifact.ps1 @@ -0,0 +1,864 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $InputBmp, + [Parameter(Mandatory)][string] $ReceiptPath, + [Parameter(Mandatory)][string] $RunReceiptPath, + [Parameter(Mandatory)][string] $TestBinaryPath, + [Parameter(Mandatory)][string] $OutputDirectory, + [Parameter(DontShow)][switch] $TestPngCodecWriteFailure +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing + +function Stop-Artifact( + [string] $Code, + [string] $Message, + [System.Exception] $InnerException = $null, + [System.Collections.IDictionary] $Evidence = $null +) { + $fullMessage = "CanvasPaintArtifact[$Code]: $Message" + $exception = if ($null -ne $InnerException) { + [InvalidOperationException]::new($fullMessage, $InnerException) + } else { + [InvalidOperationException]::new($fullMessage) + } + if ($null -ne $Evidence) { + foreach ($key in $Evidence.Keys) { + $exception.Data[$key] = $Evidence[$key] + } + } + throw $exception +} + +function Get-Sha256Bytes([byte[]] $Bytes) { + $sha = [Security.Cryptography.SHA256]::Create() + try { return [Convert]::ToHexString($sha.ComputeHash($Bytes)).ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Get-Sha256File([string] $Path) { + $stream = [IO.File]::OpenRead($Path) + $sha = [Security.Cryptography.SHA256]::Create() + try { return [Convert]::ToHexString($sha.ComputeHash($stream)).ToLowerInvariant() } + finally { + $sha.Dispose() + $stream.Dispose() + } +} + +function Read-JsonArtifact([string] $Path, [string] $ErrorCode) { + try { + $bytes = [IO.File]::ReadAllBytes($Path) + $text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + $value = ConvertFrom-Json -InputObject $text -AsHashtable -ErrorAction Stop + return [pscustomobject]@{ + Value = $value + Sha256 = Get-Sha256Bytes $bytes + } + } catch { + Stop-Artifact $ErrorCode "Could not read a valid UTF-8 JSON artifact at $Path`: $($_.Exception.Message)" + } +} + +function Read-StrictUtf8Artifact([string] $Path, [string] $ErrorCode) { + try { + $bytes = [IO.File]::ReadAllBytes($Path) + return [pscustomobject]@{ + Text = [Text.UTF8Encoding]::new($false, $true).GetString($bytes) + Sha256 = Get-Sha256Bytes $bytes + } + } catch { + Stop-Artifact $ErrorCode "Could not read a valid UTF-8 artifact at $Path`: $($_.Exception.Message)" + } +} + +function Assert-NativeArtifactBinding( + [System.Collections.IDictionary] $Artifacts, + [string] $Name, + [string] $ExpectedPath, + [string] $ActualHash, + [string] $MismatchCode +) { + $artifact = $Artifacts[$Name] + if ($artifact -isnot [System.Collections.IDictionary] -or + $artifact.present -isnot [bool] -or -not $artifact.present) { + Stop-Artifact "NativeArtifactMissing" "Native run receipt does not record a present $Name artifact." + } + if ([string]::IsNullOrWhiteSpace([string]$artifact.path) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals( + [IO.Path]::GetFullPath([string]$artifact.path), + [IO.Path]::GetFullPath($ExpectedPath))) { + Stop-Artifact "NativeArtifactReceiptInvalid" "Native run receipt path does not identify the canonical $Name artifact." + } + $recordedHash = [string]$artifact.sha256 + if ($recordedHash -cnotmatch '^[0-9a-f]{64}$') { + Stop-Artifact "NativeArtifactReceiptInvalid" "Native run receipt must contain a lowercase SHA-256 for $Name." + } + if ($ActualHash -cne $recordedHash) { + Stop-Artifact $MismatchCode "The $Name bytes differ from the hash captured immediately after the native command." + } +} + +function Test-PathWithin([string] $Root, [string] $Path) { + $rootFull = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar) + $pathFull = [IO.Path]::GetFullPath($Path) + $relative = [IO.Path]::GetRelativePath($rootFull, $pathFull) + return -not [IO.Path]::IsPathRooted($relative) -and + $relative -ne ".." -and + -not $relative.StartsWith(".." + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) +} + +function Test-HasReparseComponent([string] $Path) { + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + $current = $root + $relative = $full.Substring($root.Length) + foreach ($part in $relative.Split([IO.Path]::DirectorySeparatorChar, [StringSplitOptions]::RemoveEmptyEntries)) { + $current = Join-Path $current $part + if (Test-Path -LiteralPath $current) { + $item = Get-Item -LiteralPath $current -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return $true } + } + } + return $false +} + +function Read-BmpHeader([string] $Path) { + $length = ([IO.FileInfo]::new($Path)).Length + if ($length -lt 54) { Stop-Artifact "TruncatedBmp" "BMP file is shorter than the 54-byte header." } + $stream = [IO.File]::OpenRead($Path) + $reader = [IO.BinaryReader]::new($stream) + try { + if ($reader.ReadByte() -ne 0x42 -or $reader.ReadByte() -ne 0x4D) { + Stop-Artifact "InvalidBmp" "Transport is not a Windows BMP." + } + $declaredLength = $reader.ReadUInt32() + [void] $reader.ReadUInt32() + $pixelOffset = $reader.ReadUInt32() + $headerSize = $reader.ReadUInt32() + $width = $reader.ReadInt32() + $height = $reader.ReadInt32() + $planes = $reader.ReadUInt16() + $bitsPerPixel = $reader.ReadUInt16() + $compression = $reader.ReadUInt32() + $imageSize = $reader.ReadUInt32() + } finally { + $reader.Dispose() + $stream.Dispose() + } + if ($declaredLength -ne $length -or $headerSize -ne 40 -or $pixelOffset -ne 54 -or + $planes -ne 1 -or $bitsPerPixel -ne 32 -or $compression -ne 0 -or $height -le 0) { + Stop-Artifact "InvalidBmp" "BMP header does not match the required 32-bit BI_RGB transport." + } + if ($width -ne 1280 -or $height -ne 720) { + Stop-Artifact "WrongGeometry" "BMP must be exactly 1280x720; found ${width}x${height}." + } + $expectedImageSize = [long]$width * $height * 4 + if ($imageSize -ne $expectedImageSize -or $pixelOffset + $expectedImageSize -ne $length) { + Stop-Artifact "TruncatedBmp" "BMP pixel buffer is incomplete or has trailing bytes." + } + return [pscustomobject]@{ + Width = $width + Height = $height + PixelOffset = $pixelOffset + ImageSize = $imageSize + } +} + +function Get-DecodedRgbSha256([Drawing.Bitmap] $Bitmap) { + $rgb = [byte[]]::new($Bitmap.Width * $Bitmap.Height * 3) + $offset = 0 + $firstColor = -1 + $nonBackgroundPixels = 0 + for ($y = 0; $y -lt $Bitmap.Height; $y++) { + for ($x = 0; $x -lt $Bitmap.Width; $x++) { + $pixel = $Bitmap.GetPixel($x, $y) + if ($pixel.A -ne 255) { Stop-Artifact "NonOpaquePixel" "Decoded SDR RGB transport contains a non-opaque pixel." } + $color = ($pixel.R -shl 16) -bor ($pixel.G -shl 8) -bor $pixel.B + if ($firstColor -lt 0) { + $firstColor = $color + } elseif ($color -ne $firstColor) { + $nonBackgroundPixels++ + } + $rgb[$offset++] = $pixel.R + $rgb[$offset++] = $pixel.G + $rgb[$offset++] = $pixel.B + } + } + if ($nonBackgroundPixels -le 10000) { + Stop-Artifact "BlankOrPartialImage" "Decoded image does not contain the minimum painted pixel coverage." + } + return (Get-Sha256Bytes $rgb) +} + +function Get-FontFileFact([string] $Name) { + $path = Join-Path $env:WINDIR ("Fonts\" + $Name) + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + return [ordered]@{ name = $Name; resolved = $false; path = $null; version = $null; sha256 = $null } + } + $item = Get-Item -LiteralPath $path + return [ordered]@{ + name = $Name + resolved = $true + path = $item.FullName + version = [Diagnostics.FileVersionInfo]::GetVersionInfo($item.FullName).FileVersion + sha256 = Get-Sha256File $item.FullName + } +} + +$inputFull = [IO.Path]::GetFullPath($InputBmp) +$receiptFull = [IO.Path]::GetFullPath($ReceiptPath) +$runReceiptFull = [IO.Path]::GetFullPath($RunReceiptPath) +$testBinaryFull = [IO.Path]::GetFullPath($TestBinaryPath) +$outputFull = [IO.Path]::GetFullPath($OutputDirectory) +$runRoot = [IO.Path]::GetDirectoryName($testBinaryFull) +$transportRoot = Join-Path $runRoot "export\transport" +$expectedInput = Join-Path $transportRoot "project-paint-v1.bmp" +$expectedReceipt = Join-Path $transportRoot "paint-receipt.json" +$expectedRunReceipt = Join-Path $runRoot "run-receipt.json" +$nativeLogPath = Join-Path $runRoot "native.log" +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..")) +$sourceFiles = @( + "graphcode-windows\src\GraphCanvas.zig", + "graphcode-windows\src\GdiplusAA.zig", + "graphcode-windows\fixtures\paint-project-v1.json", + "Tools\windows\Convert-CanvasPaintArtifact.ps1", + "Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1", + "Tools\windows\Tests\WindowsShell.Tests.ps1", + "graphcode-windows\README.md" +) +if (-not [StringComparer]::OrdinalIgnoreCase.Equals($inputFull, $expectedInput) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals($receiptFull, $expectedReceipt) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals($runReceiptFull, $expectedRunReceipt)) { + Stop-Artifact "TransportPathInvalid" "BMP, paint receipt, and run receipt must be the canonical files for this native test run." +} +if (-not (Test-PathWithin $runRoot $outputFull)) { + Stop-Artifact "OutputPathEscape" "Output directory must remain inside the owned native-test run root." +} +if (Test-PathWithin $transportRoot $outputFull) { + Stop-Artifact "OutputPathOverlapsTransport" "Published output must remain separate from BMP transport and receipt files." +} +if (-not (Test-Path -LiteralPath $testBinaryFull -PathType Leaf)) { + Stop-Artifact "TestBinaryMissing" "The emitted native test binary is missing." +} +if (-not (Test-Path -LiteralPath $nativeLogPath -PathType Leaf) -or + (Get-Item -LiteralPath $nativeLogPath).Length -eq 0) { + Stop-Artifact "NativeLogMissing" "The native test run log is missing or empty." +} +if (-not (Test-Path -LiteralPath $inputFull -PathType Leaf)) { + Stop-Artifact "InputBmpMissing" "The canonical BMP transport is missing." +} +if (-not (Test-Path -LiteralPath $receiptFull -PathType Leaf)) { + Stop-Artifact "ReceiptMissing" "The paint receipt is missing." +} +if (-not (Test-Path -LiteralPath $runReceiptFull -PathType Leaf)) { + Stop-Artifact "RunReceiptMissing" "The frozen native test run receipt is missing." +} +foreach ($path in @($runRoot, $testBinaryFull, $transportRoot, $inputFull, $receiptFull, $runReceiptFull, $nativeLogPath, $outputFull)) { + if (Test-HasReparseComponent $path) { + Stop-Artifact "ReparsePoint" "Artifact paths may not traverse reparse points: $path" + } +} +if (Test-Path -LiteralPath $outputFull) { + Stop-Artifact "OutputNotFresh" "Output directory must not already exist." +} + +$receiptDocument = Read-JsonArtifact $receiptFull "ReceiptSchemaInvalid" +$receipt = $receiptDocument.Value +$runReceiptDocument = Read-JsonArtifact $runReceiptFull "NativeRunReceiptInvalid" +$runReceipt = $runReceiptDocument.Value +if ($runReceipt -isnot [System.Collections.IDictionary] -or + [string]::IsNullOrWhiteSpace([string]$runReceipt.runId) -or + [string]::IsNullOrWhiteSpace([string]$runReceipt.exportDirectory) -or + [string]::IsNullOrWhiteSpace([string]$runReceipt.emittedTestBinary) -or + [string]::IsNullOrWhiteSpace([string]$runReceipt.log)) { + Stop-Artifact "NativeRunReceiptInvalid" "Run receipt is missing its run, export, binary, or log identity." +} +if ($receipt.schemaVersion -ne 1 -or $receipt.evidenceClass -cne "paint-only" -or + $receipt.scene -cne "project-paint-v1" -or $receipt.renderer -cne "GraphCanvas.paint + GraphCanvas.paintHeader -> memory DIB") { + Stop-Artifact "ReceiptSchemaInvalid" "Receipt does not describe the approved paint-only render." +} +if ($receipt.sourceCommit -cnotmatch '^[0-9a-f]{40}$') { + Stop-Artifact "SourceCommitInvalid" "Receipt source commit must be a full lowercase Git object ID." +} +try { + $runExportFull = [IO.Path]::GetFullPath([string]$runReceipt.exportDirectory) + $runBinaryFull = [IO.Path]::GetFullPath([string]$runReceipt.emittedTestBinary) + $runLogFull = [IO.Path]::GetFullPath([string]$runReceipt.log) +} catch { + Stop-Artifact "NativeRunReceiptInvalid" "Run receipt contains an invalid artifact path: $($_.Exception.Message)" +} +if ($runReceipt.runId -cnotmatch '^[a-z0-9-]+$' -or + $runReceipt.runId -cne [IO.Path]::GetFileName($runRoot) -or + $runReceipt.sourceHeadBefore -cnotmatch '^[0-9a-f]{40}$' -or + $runReceipt.sourceHeadAfter -cne $runReceipt.sourceHeadBefore -or + $receipt.sourceCommit -cne $runReceipt.sourceHeadBefore -or + -not [StringComparer]::OrdinalIgnoreCase.Equals( + $runExportFull, + [IO.Path]::GetFullPath((Join-Path $runRoot "export")))) { + Stop-Artifact "NativeRunReceiptInvalid" "Run identity, base HEAD, or exported artifact directory does not match the canonical native run." +} +if (-not [StringComparer]::OrdinalIgnoreCase.Equals( + $runBinaryFull, $testBinaryFull) -or + -not [StringComparer]::OrdinalIgnoreCase.Equals($runLogFull, $nativeLogPath)) { + Stop-Artifact "NativeRunReceiptInvalid" "Run receipt test-binary or log identity does not match the converter inputs." +} +$nativeCommand = [string]$runReceipt.command +if ([string]::IsNullOrWhiteSpace($nativeCommand) -or + -not $nativeCommand.Contains("test src\GraphCanvas.zig") -or + -not $nativeCommand.Contains('--test-filter "paint export: project-paint-v1"') -or + $nativeCommand.IndexOf($testBinaryFull, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + Stop-Artifact "NativeRunReceiptInvalid" "Run receipt command does not identify the approved filtered GraphCanvas paint test and emitted binary." +} +if (($runReceipt.nativeExitCode -isnot [int]) -and ($runReceipt.nativeExitCode -isnot [long])) { + Stop-Artifact "NativeRunReceiptInvalid" "Run receipt nativeExitCode must be a JSON integer." +} +if ([long]$runReceipt.nativeExitCode -ne 0) { + Stop-Artifact "NativeRunFailed" "The native paint test did not exit successfully." +} + +$nativeLogDocument = Read-StrictUtf8Artifact $nativeLogPath "NativeRunSummaryInvalid" +if ($runReceipt.nativeArtifacts -isnot [System.Collections.IDictionary]) { + Stop-Artifact "NativeArtifactReceiptInvalid" "Run receipt is missing native artifact hash facts." +} +if ($runReceipt.nativeArtifacts.capturedImmediatelyAfterNativeCommand -isnot [bool] -or + -not $runReceipt.nativeArtifacts.capturedImmediatelyAfterNativeCommand) { + Stop-Artifact "NativeArtifactReceiptInvalid" "Native artifact hashes must be recorded immediately after the native command." +} +Assert-NativeArtifactBinding $runReceipt.nativeArtifacts "testBinary" $testBinaryFull ` + (Get-Sha256File $testBinaryFull) "NativeTestBinaryHashMismatch" +Assert-NativeArtifactBinding $runReceipt.nativeArtifacts "nativeLog" $nativeLogPath ` + $nativeLogDocument.Sha256 "NativeLogHashMismatch" +Assert-NativeArtifactBinding $runReceipt.nativeArtifacts "paintReceipt" $receiptFull ` + $receiptDocument.Sha256 "NativePaintReceiptHashMismatch" +$nativeLogLines = @($nativeLogDocument.Text -split "\r?\n" | Where-Object { $_.Length -gt 0 }) +$nativeResultLines = @($nativeLogLines | Where-Object { $_ -match '^\d+/\d+ ' }) +$nativeTestLines = @($nativeLogLines | Where-Object { $_ -match '^1/1 GraphCanvas\.test\.paint export: project-paint-v1' }) +$nativeSummaries = @($nativeLogLines | Where-Object { $_ -match '^All \d+ tests (?:passed|failed|skipped)\.$' }) +$nativeFailureMarkers = @($nativeLogLines | Where-Object { $_ -match '(?i)\b(?:FAIL|FAILED|SKIP|SKIPPED|ERROR|PANIC)\b' }) +if ($nativeResultLines.Count -ne 1 -or $nativeTestLines.Count -ne 1 -or $nativeSummaries.Count -ne 1 -or + $nativeSummaries[0] -cne "All 1 tests passed." -or + $nativeFailureMarkers.Count -ne 0) { + Stop-Artifact "NativeRunSummaryInvalid" "Native log must contain exactly one named 1/1 paint test, one positive pass summary, and no failure or skip marker." +} +$nativeTestLineIndex = [Array]::IndexOf($nativeLogLines, $nativeTestLines[0]) +if ($nativeTestLineIndex -lt 0 -or $nativeTestLineIndex + 1 -ge $nativeLogLines.Count -or + $nativeLogLines[$nativeTestLineIndex + 1] -cne "OK") { + Stop-Artifact "NativeRunSummaryInvalid" "The named paint test does not have an unambiguous OK result." +} + +$expectedSourceKeys = @($sourceFiles | Sort-Object -CaseSensitive) +if ($runReceipt.sourceHashesBefore -isnot [System.Collections.IDictionary] -or + $runReceipt.sourceHashesAfter -isnot [System.Collections.IDictionary]) { + Stop-Artifact "NativeSourceSnapshotInvalid" "Run receipt must contain before and after source hash maps." +} +$beforeKeys = @($runReceipt.sourceHashesBefore.Keys | Sort-Object -CaseSensitive) +$afterKeys = @($runReceipt.sourceHashesAfter.Keys | Sort-Object -CaseSensitive) +if (($beforeKeys -join "`n") -cne ($expectedSourceKeys -join "`n") -or + ($afterKeys -join "`n") -cne ($expectedSourceKeys -join "`n")) { + Stop-Artifact "NativeSourceSnapshotInvalid" "Run receipt must contain exactly the seven leased source paths before and after the native test." +} +$sourceHashes = [Collections.Generic.List[object]]::new() +foreach ($relativePath in $sourceFiles) { + $fullPath = Join-Path $repoRoot $relativePath + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf) -or (Test-HasReparseComponent $fullPath)) { + Stop-Artifact "SourceFileMissing" "Required source/provenance file is missing or a reparse point: $relativePath" + } + $beforeHash = [string]$runReceipt.sourceHashesBefore[$relativePath] + $afterHash = [string]$runReceipt.sourceHashesAfter[$relativePath] + if ($beforeHash -cnotmatch '^[0-9a-f]{64}$' -or $afterHash -cnotmatch '^[0-9a-f]{64}$') { + Stop-Artifact "NativeSourceSnapshotInvalid" "Run receipt source hashes must be full lowercase SHA-256 values: $relativePath" + } + if ($beforeHash -cne $afterHash) { + Stop-Artifact "NativeSourceChangedDuringRun" "Source changed while the native test ran: $relativePath" + } + $currentHash = Get-Sha256File $fullPath + if ($currentHash -cne $beforeHash) { + Stop-Artifact "NativeSourceHashMismatch" "Current source bytes do not match the native run for $relativePath." + } + $sourceHashes.Add([ordered]@{ path = $relativePath.Replace("\", "/"); sha256 = $beforeHash }) +} +if ($receipt.dimensions.width -ne 1280 -or $receipt.dimensions.height -ne 720 -or + $receipt.dimensions.logicalWidth -ne 1280 -or $receipt.dimensions.logicalHeight -ne 720 -or + $receipt.dpiMapping.windowExtX -ne 96 -or $receipt.dpiMapping.windowExtY -ne 96 -or + $receipt.dpiMapping.viewportExtX -ne 96 -or $receipt.dpiMapping.viewportExtY -ne 96 -or + $receipt.canvas.zoom -ne 1 -or $receipt.canvas.panX -ne 0 -or $receipt.canvas.panY -ne 0 -or + $receipt.canvas.selectedIndex -ne 0 -or -not $receipt.canvas.railVisible -or + $receipt.canvas.panelVisible -or $receipt.canvas.activityEnabled) { + Stop-Artifact "RenderConfigurationInvalid" "Receipt does not match the fixed 1280x720 canvas configuration." +} +if ($receipt.dpiMapping.deviceDpiX -le 0 -or $receipt.dpiMapping.deviceDpiY -le 0) { + Stop-Artifact "DeviceMetricsInvalid" "Receipt must contain positive device-context DPI metrics." +} +if ($receipt.model.nodes -ne 2 -or $receipt.model.edges -ne 1 -or + $receipt.model.activityHistory -ne 0 -or $receipt.model.createdAtValues -ne 0 -or + $receipt.header.title -cne "GraphCode" -or $receipt.header.context -cne "Alpha" -or + $receipt.gdiPlusReady -ne $true -or $receipt.candidateArtifactCount -ne 1) { + Stop-Artifact "PaintEvidenceInvalid" "Receipt model census, header, readiness, or artifact count is invalid." +} +if ($receipt.header.reachabilityClaim -cne "declared fixture/test value only; not App reachability") { + Stop-Artifact "HeaderClaimInvalid" "Header provenance must not imply App reachability." +} +if ($receipt.colorAssumption -cne "SDR RGB; unused DIB alpha is normalized to opaque in BMP transport") { + Stop-Artifact "ColorAssumptionInvalid" "Receipt must state the SDR RGB and opaque-alpha transport assumptions." +} +if ($receipt.fontEvidenceScope -cne "Sampled card title, state, metadata, and header title font selections only; not a census of every text draw") { + Stop-Artifact "FontEvidenceScopeInvalid" "Receipt must state the limits of its sampled font evidence." +} +$expectedExcludedSurfaces = @( + "whole window", + "composition", + "popups", + "terminals", + "per-monitor DPI", + "sidebar/right-panel fidelity", + "native form controls", + "accessibility" +) +if (($receipt.excludedSurfaces -join "`n") -cne ($expectedExcludedSurfaces -join "`n")) { + Stop-Artifact "ExcludedSurfacesInvalid" "Receipt does not preserve the approved evidence limits." +} + +$fixturePath = Join-Path $repoRoot "graphcode-windows\fixtures\paint-project-v1.json" +$actualFixtureHash = Get-Sha256File $fixturePath +if ($receipt.fixturePath -cne "graphcode-windows/fixtures/paint-project-v1.json" -or + $receipt.fixtureSha256 -cne $actualFixtureHash) { + Stop-Artifact "FixtureHashMismatch" "Receipt fixture identity does not match the bytes in the repository." +} + +$expectedFontRoles = @( + @{ role = "card-title"; face = "Segoe UI"; height = -15; weight = 600 }, + @{ role = "card-state"; face = "Segoe UI"; height = -10; weight = 700 }, + @{ role = "card-mono"; face = "Consolas"; height = -11; weight = 400 }, + @{ role = "header"; face = "Segoe UI"; height = -11; weight = 400 } +) +if (@($receipt.fonts).Count -ne $expectedFontRoles.Count) { + Stop-Artifact "FontFactsInvalid" "Receipt must include all four sampled font facts." +} +for ($index = 0; $index -lt $expectedFontRoles.Count; $index++) { + $font = $receipt.fonts[$index] + if ($font.role -cne $expectedFontRoles[$index].role -or + $font.logicalFace -cne $expectedFontRoles[$index].face -or + $font.actualFace -cne $expectedFontRoles[$index].face -or + $font.quality -cne "CLEARTYPE_QUALITY" -or + $font.height -ne $expectedFontRoles[$index].height -or + $font.weight -ne $expectedFontRoles[$index].weight) { + Stop-Artifact "FontFactsInvalid" "Requested and selected font facts differ for $($expectedFontRoles[$index].role)." + } +} + +$expectedTransportNames = @( + "project-paint-v1.bmp", + "project-paint-v1-repeat-2.bmp", + "project-paint-v1-repeat-3.bmp" +) +if (@($receipt.transport).Count -ne $expectedTransportNames.Count -or + @($receipt.renderRgbSha256).Count -ne 3 -or + $receipt.renderRgbSha256[0] -cne $receipt.renderRgbSha256[1] -or + $receipt.renderRgbSha256[0] -cne $receipt.renderRgbSha256[2]) { + Stop-Artifact "RenderRepeatMismatch" "Three independent DIB renders must have identical RGB hashes." +} +$transportNames = @(Get-ChildItem -LiteralPath $transportRoot -Force | ForEach-Object { $_.Name } | Sort-Object) +$expectedNames = @($expectedTransportNames + "paint-receipt.json") +[Array]::Sort($expectedNames, [StringComparer]::Ordinal) +if (($transportNames -join "`n") -cne ($expectedNames -join "`n")) { + Stop-Artifact "TransportContentsInvalid" "Transport must contain only the three expected BMPs and the receipt." +} + +$transportFacts = [Collections.Generic.List[object]]::new() +$canonicalBitmap = $null +for ($index = 0; $index -lt $expectedTransportNames.Count; $index++) { + $bmpPath = Join-Path $transportRoot $expectedTransportNames[$index] + if (-not (Test-Path -LiteralPath $bmpPath -PathType Leaf)) { + Stop-Artifact "RepeatBmpMissing" "Expected independent render transport is missing: $($expectedTransportNames[$index])" + } + if (Test-HasReparseComponent $bmpPath) { Stop-Artifact "ReparsePoint" "BMP transport may not be a reparse point." } + $bmpHeader = Read-BmpHeader $bmpPath + $bitmap = [Drawing.Bitmap]::new($bmpPath) + try { + if ($bitmap.Width -ne $bmpHeader.Width -or $bitmap.Height -ne $bmpHeader.Height) { + Stop-Artifact "BmpDecodeGeometryMismatch" "Decoded BMP dimensions differ from its header." + } + $decodedRgbHash = Get-DecodedRgbSha256 $bitmap + if ($decodedRgbHash -cne $receipt.transport[$index].rgbSha256 -or + $decodedRgbHash -cne $receipt.renderRgbSha256[$index]) { + Stop-Artifact "BmpRgbHashMismatch" "Decoded RGB pixels differ from the native paint receipt." + } + if ($receipt.transport[$index].name -cne $expectedTransportNames[$index]) { + Stop-Artifact "TransportContentsInvalid" "Receipt transport names do not match the canonical files." + } + $bmpHash = Get-Sha256File $bmpPath + if ($receipt.transport[$index].bmpSha256 -and $receipt.transport[$index].bmpSha256 -cne $bmpHash) { + Stop-Artifact "BmpHashMismatch" "BMP file hash does not match the native receipt." + } + if ($index -eq 0) { + if ($bmpHash -cne $receipt.transport[0].bmpSha256) { + Stop-Artifact "BmpHashMismatch" "Canonical BMP file hash does not match the native receipt." + } + $canonicalBitmap = $bitmap + } else { + $bitmap.Dispose() + } + $transportFacts.Add([ordered]@{ + name = $expectedTransportNames[$index] + bmpSha256 = $bmpHash + decodedRgbSha256 = $decodedRgbHash + }) + } catch { + $bitmap.Dispose() + if ($null -ne $canonicalBitmap) { + $canonicalBitmap.Dispose() + $canonicalBitmap = $null + } + throw + } +} + +$stagingFull = Join-Path $runRoot (".canvas-paint-staging-" + [guid]::NewGuid().ToString("N")) +$stagingDirectoryCreated = $false +$published = $false +try { + try { + New-Item -ItemType Directory -Path $stagingFull -ErrorAction Stop | Out-Null + $stagingDirectoryCreated = $true + } catch { + Stop-Artifact "OutputWriteFailed" "Could not create a fresh staging directory: $($_.Exception.Message)" + } + $pngPath = Join-Path $stagingFull "project-paint-v1.png" + $manifestPath = Join-Path $stagingFull "paint-only-manifest.json" + $pngFileStream = $null + $pngStream = $null + try { + if ($TestPngCodecWriteFailure -and + -not ("GraphCode.PaintArtifactTests.ThrowAfterPartialWriteStream" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Threading; + +namespace GraphCode.PaintArtifactTests +{ + public sealed class ThrowAfterPartialWriteStream : Stream + { + private readonly Stream inner; + private long writeCallbackCount; + private long partialBytesWritten; + private int injectedIOExceptionObserved; + private IOException injectedIOException; + + public ThrowAfterPartialWriteStream(Stream inner) => this.inner = inner; + + public long WriteCallbackCount => Interlocked.Read(ref writeCallbackCount); + public long PartialBytesWritten => Interlocked.Read(ref partialBytesWritten); + public bool InjectedIOExceptionObserved => Volatile.Read(ref injectedIOExceptionObserved) != 0; + public IOException InjectedIOException => injectedIOException; + public override bool CanRead => inner.CanRead; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + public override long Position { get => inner.Position; set => inner.Position = value; } + + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + { + Interlocked.Increment(ref writeCallbackCount); + if (count > 0) + { + int partialCount = Math.Max(1, count / 2); + inner.Write(buffer, offset, partialCount); + Interlocked.Add(ref partialBytesWritten, partialCount); + } + ThrowInjectedIOException(); + } + + public override void Write(ReadOnlySpan buffer) + { + Interlocked.Increment(ref writeCallbackCount); + if (buffer.Length > 0) + { + int partialCount = Math.Max(1, buffer.Length / 2); + inner.Write(buffer.Slice(0, partialCount)); + Interlocked.Add(ref partialBytesWritten, partialCount); + } + ThrowInjectedIOException(); + } + + public override void WriteByte(byte value) + { + Interlocked.Increment(ref writeCallbackCount); + inner.WriteByte(value); + Interlocked.Increment(ref partialBytesWritten); + ThrowInjectedIOException(); + } + + private void ThrowInjectedIOException() + { + var exception = new IOException("Injected PNG staging stream write failure."); + injectedIOException = exception; + Volatile.Write(ref injectedIOExceptionObserved, 1); + throw exception; + } + + protected override void Dispose(bool disposing) + { + if (disposing) inner.Dispose(); + base.Dispose(disposing); + } + } +} +'@ -ErrorAction Stop + } + $pngFileStream = [IO.File]::Open($pngPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + $pngStream = if ($TestPngCodecWriteFailure) { + [GraphCode.PaintArtifactTests.ThrowAfterPartialWriteStream]::new($pngFileStream) + } else { + $pngFileStream + } + $saveException = $null + $flushException = $null + $saveReturnedNormally = $false + try { + $canonicalBitmap.Save($pngStream, [Drawing.Imaging.ImageFormat]::Png) + $saveReturnedNormally = $true + } catch { + $saveException = $_.Exception + } + if ($saveReturnedNormally) { + try { + if ($TestPngCodecWriteFailure) { $pngStream.Flush() } else { $pngFileStream.Flush($true) } + } catch { + $flushException = $_.Exception + } + } + } finally { + if ($null -ne $pngStream) { + $pngStream.Dispose() + } elseif ($null -ne $pngFileStream) { + $pngFileStream.Dispose() + } + } + if ($TestPngCodecWriteFailure) { + $writeCallbackCount = [long]$pngStream.WriteCallbackCount + $partialBytesWritten = [long]$pngStream.PartialBytesWritten + $injectedIOExceptionObserved = [bool]$pngStream.InjectedIOExceptionObserved + $injectedIOException = $pngStream.InjectedIOException + $propagatedException = if ($null -ne $saveException) { $saveException } else { $flushException } + $evidence = [ordered]@{ + saveReturnedNormally = [bool]$saveReturnedNormally + saveException = $saveException + flushException = $flushException + writeCallbackCount = $writeCallbackCount + partialBytesWritten = $partialBytesWritten + injectedIOExceptionObserved = $injectedIOExceptionObserved + injectedIOException = $injectedIOException + propagatedException = $propagatedException + } + $details = "Bitmap.Save returned normally=$saveReturnedNormally; write callbacks=$writeCallbackCount; " + + "partial bytes successfully written=$partialBytesWritten; injected IOException observed=$injectedIOExceptionObserved." + if ($null -ne $saveException) { + $details += (" Propagated Bitmap.Save exception HRESULT=0x{0:X8}: {1}" -f + $saveException.HResult, $saveException.ToString()) + } + if ($null -ne $flushException) { + $details += (" Propagated stream flush exception HRESULT=0x{0:X8}: {1}" -f + $flushException.HResult, $flushException.ToString()) + } + if ($null -ne $injectedIOException) { + $details += (" Injected IOException HRESULT=0x{0:X8}: {1}" -f + $injectedIOException.HResult, $injectedIOException.ToString()) + } + if (-not $injectedIOExceptionObserved -or $writeCallbackCount -le 0 -or + $partialBytesWritten -le 0 -or $null -eq $injectedIOException) { + Stop-Artifact "CodecFaultNotObserved" ` + "The requested codec-write fault was not proven at the stream boundary. $details" ` + $propagatedException $evidence + } + $failureCause = $injectedIOException + if ($null -ne $propagatedException -and + -not [object]::ReferenceEquals($propagatedException, $injectedIOException)) { + $failureCause = [AggregateException]::new( + "The injected stream IOException and propagated Save/flush exception are both retained.", + [System.Exception[]]@($injectedIOException, $propagatedException)) + } elseif ($null -ne $propagatedException) { + $failureCause = $propagatedException + } + Stop-Artifact "CodecWriteFailed" $details $failureCause $evidence + } + if ($null -ne $saveException -or $null -ne $flushException) { + $propagatedException = if ($null -ne $saveException) { $saveException } else { $flushException } + $details = "Bitmap.Save returned normally=$saveReturnedNormally. PNG codec or output stream failed: " + + $propagatedException.ToString() + Stop-Artifact "CodecWriteFailed" $details $propagatedException ([ordered]@{ + saveReturnedNormally = [bool]$saveReturnedNormally + saveException = $saveException + flushException = $flushException + propagatedException = $propagatedException + }) + } + + $pngBitmap = [Drawing.Bitmap]::new($pngPath) + try { + if ($pngBitmap.Width -ne 1280 -or $pngBitmap.Height -ne 720) { + Stop-Artifact "PngGeometryMismatch" "PNG decode changed the 1280x720 canvas dimensions." + } + $pngRgbHash = Get-DecodedRgbSha256 $pngBitmap + } finally { + $pngBitmap.Dispose() + } + if ($pngRgbHash -cne $receipt.renderRgbSha256[0]) { + Stop-Artifact "PngRgbHashMismatch" "PNG decoded RGB pixels differ from the native paint render." + } + + foreach ($sourceFact in $sourceHashes) { + $fullPath = Join-Path $repoRoot $sourceFact.path.Replace("/", "\") + if ((Get-Sha256File $fullPath) -cne $sourceFact.sha256) { + Stop-Artifact "NativeSourceChangedDuringConversion" "Source bytes changed while the PNG and manifest were being prepared: $($sourceFact.path)" + } + } + foreach ($nativeArtifact in @( + @{ Path = $testBinaryFull; Hash = [string]$runReceipt.nativeArtifacts.testBinary.sha256; Code = "NativeTestBinaryChangedDuringConversion"; Name = "test binary" }, + @{ Path = $nativeLogPath; Hash = [string]$runReceipt.nativeArtifacts.nativeLog.sha256; Code = "NativeLogChangedDuringConversion"; Name = "native log" }, + @{ Path = $receiptFull; Hash = [string]$runReceipt.nativeArtifacts.paintReceipt.sha256; Code = "NativePaintReceiptChangedDuringConversion"; Name = "paint receipt" } + )) { + if ((Get-Sha256File $nativeArtifact.Path) -cne $nativeArtifact.Hash) { + Stop-Artifact $nativeArtifact.Code "The $($nativeArtifact.Name) changed while the PNG and manifest were being prepared." + } + } + + $fontFiles = @( + (Get-FontFileFact "seguisb.ttf"), + (Get-FontFileFact "segoeuib.ttf"), + (Get-FontFileFact "consola.ttf"), + (Get-FontFileFact "segoeui.ttf") + ) + $fontFacts = @() + for ($index = 0; $index -lt $expectedFontRoles.Count; $index++) { + $fontFacts += [ordered]@{ + role = $receipt.fonts[$index].role + logicalFace = $receipt.fonts[$index].logicalFace + actualFace = $receipt.fonts[$index].actualFace + height = $receipt.fonts[$index].height + weight = $receipt.fonts[$index].weight + quality = $receipt.fonts[$index].quality + file = $fontFiles[$index] + } + } + $manifest = [ordered]@{ + schemaVersion = 1 + evidenceClass = "real paint path, offscreen component render" + artifactCount = 1 + artifact = [ordered]@{ + name = "project-paint-v1.png" + width = 1280 + height = 720 + pngSha256 = Get-Sha256File $pngPath + decodedRgbSha256 = $pngRgbHash + } + scene = $receipt.scene + renderer = $receipt.renderer + fixture = [ordered]@{ + path = $receipt.fixturePath + sha256 = $actualFixtureHash + sourceBaseCommit = $receipt.sourceCommit + sourceBaseCommitRole = "Base HEAD only; sourceFiles SHA-256 values bind the seven leased files, not a clean-tree snapshot of other imported modules or their dirty/untracked state." + } + sourceFiles = $sourceHashes.ToArray() + nativeRun = [ordered]@{ + runId = $runReceipt.runId + runReceiptPath = $runReceiptFull + runReceiptSha256 = $runReceiptDocument.Sha256 + nativeExitCode = $runReceipt.nativeExitCode + testName = "GraphCanvas.test.paint export: project-paint-v1" + testSummary = $nativeSummaries[0] + sourceHeadBefore = $runReceipt.sourceHeadBefore + sourceHeadAfter = $runReceipt.sourceHeadAfter + artifactHashes = [ordered]@{ + capturedImmediatelyAfterNativeCommand = $runReceipt.nativeArtifacts.capturedImmediatelyAfterNativeCommand + testBinarySha256 = $runReceipt.nativeArtifacts.testBinary.sha256 + nativeLogSha256 = $runReceipt.nativeArtifacts.nativeLog.sha256 + paintReceiptSha256 = $runReceipt.nativeArtifacts.paintReceipt.sha256 + identityScope = "Hashes bind the exact artifact bytes observed after the native command to conversion inputs; they are not cryptographic execution attestation." + } + } + paintReceipt = [ordered]@{ + path = $receiptFull + sha256 = $runReceipt.nativeArtifacts.paintReceipt.sha256 + } + testBinary = [ordered]@{ + path = $testBinaryFull + sha256 = $runReceipt.nativeArtifacts.testBinary.sha256 + identityNote = "SHA-256 was captured immediately after the native command and verified unchanged at conversion; identity is provenance, not cryptographic execution attestation." + } + nativeLog = [ordered]@{ + path = $nativeLogPath + sha256 = $runReceipt.nativeArtifacts.nativeLog.sha256 + } + environment = [ordered]@{ + osVersion = [Environment]::OSVersion.Version.ToString() + platform = [Environment]::OSVersion.Platform.ToString() + zigVersion = "0.15.2" + winghosttyPin = "6286560d0aa3103e068b2b7afa81eac373d870c9" + pinEvidenceScope = "Expected repository pins; actual Zig executable and Winghostty checkout identity are not measured by the converter." + } + dimensions = $receipt.dimensions + dpiMapping = $receipt.dpiMapping + canvas = $receipt.canvas + model = $receipt.model + header = $receipt.header + gdiPlusReady = $receipt.gdiPlusReady + colorAssumption = $receipt.colorAssumption + fontEvidenceScope = $receipt.fontEvidenceScope + fonts = $fontFacts + transport = $transportFacts.ToArray() + renderRgbSha256 = $receipt.renderRgbSha256 + determinismScope = "Three fresh memory-DIB renders on this host/build/fixture only; not a golden or cross-machine guarantee." + gdiPlusEvidenceScope = "GDI+ readiness is observed; this does not prove every drawing operation used GDI+ instead of the existing GDI fallback." + fontFileEvidenceScope = "actualFace is the selected face reported by GetTextFace/LOGFONT; file entries are lookup candidates and do not identify the selected physical font file." + excludedSurfaces = $receipt.excludedSurfaces + } + $json = $manifest | ConvertTo-Json -Depth 12 + $manifestBytes = [Text.UTF8Encoding]::new($false).GetBytes($json + "`n") + $manifestPartial = $manifestPath + ".partial" + $manifestStream = [IO.File]::Open($manifestPartial, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $manifestStream.Write($manifestBytes, 0, $manifestBytes.Length) + $manifestStream.Flush($true) + } finally { + $manifestStream.Dispose() + } + [IO.File]::Move($manifestPartial, $manifestPath) + $publishedNames = @(Get-ChildItem -LiteralPath $stagingFull -Force | ForEach-Object { $_.Name } | Sort-Object) + $expectedPublishedNames = @("paint-only-manifest.json", "project-paint-v1.png") + [Array]::Sort($expectedPublishedNames, [StringComparer]::Ordinal) + if (($publishedNames -join "`n") -cne ($expectedPublishedNames -join "`n")) { + Stop-Artifact "PublishedContentsInvalid" "Published output must contain exactly one PNG and one manifest." + } + if (Test-Path -LiteralPath $outputFull) { + Stop-Artifact "OutputNotFresh" "Output directory appeared while the verified artifact was being prepared." + } + try { + [IO.Directory]::Move($stagingFull, $outputFull) + $published = $true + } catch { + Stop-Artifact "OutputPublishFailed" "Could not atomically publish the verified output from $stagingFull`: $($_.Exception.Message)" + } + [pscustomobject]@{ + Artifact = (Join-Path $outputFull "project-paint-v1.png") + Manifest = (Join-Path $outputFull "paint-only-manifest.json") + ArtifactCount = 1 + PngSha256 = $manifest.artifact.pngSha256 + RgbSha256 = $pngRgbHash + FixtureSha256 = $actualFixtureHash + TestBinarySha256 = $manifest.testBinary.sha256 + } +} finally { + if ($stagingDirectoryCreated -and -not $published) { + Write-Warning "Failed artifact staging preserved at $stagingFull" + } + if ($null -ne $canonicalBitmap) { $canonicalBitmap.Dispose() } +} diff --git a/Tools/windows/Tests/CanvasPaintArtifact.Tests.ps1 b/Tools/windows/Tests/CanvasPaintArtifact.Tests.ps1 new file mode 100644 index 00000000..f68c75b9 --- /dev/null +++ b/Tools/windows/Tests/CanvasPaintArtifact.Tests.ps1 @@ -0,0 +1,566 @@ +[CmdletBinding()] +param([Parameter(Mandatory)][string] $ScratchRoot) + +$ErrorActionPreference = "Stop" +$scratchFull = [IO.Path]::GetFullPath($ScratchRoot) +if (Test-Path -LiteralPath $scratchFull) { throw "Refusing existing converter contract root: $scratchFull" } +New-Item -ItemType Directory -Path $scratchFull | Out-Null +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\..")) +$converter = Join-Path $PSScriptRoot "..\Convert-CanvasPaintArtifact.ps1" +$fixturePath = Join-Path $repoRoot "graphcode-windows\fixtures\paint-project-v1.json" +$fixtureHash = (Get-FileHash -LiteralPath $fixturePath -Algorithm SHA256).Hash.ToLowerInvariant() +$sourcePaths = @( + "graphcode-windows\src\GraphCanvas.zig", + "graphcode-windows\src\GdiplusAA.zig", + "graphcode-windows\fixtures\paint-project-v1.json", + "Tools\windows\Convert-CanvasPaintArtifact.ps1", + "Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1", + "Tools\windows\Tests\WindowsShell.Tests.ps1", + "graphcode-windows\README.md" +) +$caseResults = [Collections.Generic.List[object]]::new() +$syntheticFixture = Join-Path $scratchFull "synthetic-codec-fixture.bmp" + +function Assert-Contract([bool] $Condition, [string] $Message) { + if (-not $Condition) { throw $Message } +} + +function Get-TestHash([byte[]] $Bytes) { + $sha = [Security.Cryptography.SHA256]::Create() + try { return [Convert]::ToHexString($sha.ComputeHash($Bytes)).ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function New-SyntheticBmp([string] $Path, [int] $Width = 1280, [int] $Height = 720) { + $rowBytes = $Width * 4 + $imageSize = $rowBytes * $Height + $fileSize = 54 + $imageSize + $bytes = [byte[]]::new($fileSize) + $bytes[0] = 0x42 + $bytes[1] = 0x4D + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint32]$fileSize), 0, $bytes, 2, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint32]54), 0, $bytes, 10, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint32]40), 0, $bytes, 14, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([int32]$Width), 0, $bytes, 18, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([int32]$Height), 0, $bytes, 22, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint16]1), 0, $bytes, 26, 2) + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint16]32), 0, $bytes, 28, 2) + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint32]0), 0, $bytes, 30, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([uint32]$imageSize), 0, $bytes, 34, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([int32]3780), 0, $bytes, 38, 4) + [Buffer]::BlockCopy([BitConverter]::GetBytes([int32]3780), 0, $bytes, 42, 4) + $pixelIndex = 0 + for ($offset = 54; $offset -lt $bytes.Length; $offset += 4) { + if ($pixelIndex % 2 -eq 0) { + $bytes[$offset] = 0x33 + $bytes[$offset + 1] = 0x22 + $bytes[$offset + 2] = 0x11 + } else { + $bytes[$offset] = 0x66 + $bytes[$offset + 1] = 0x55 + $bytes[$offset + 2] = 0x44 + } + $bytes[$offset + 3] = 0xFF + $pixelIndex++ + } + [IO.File]::WriteAllBytes($Path, $bytes) +} + +function New-SyntheticRgbHash([int] $Width = 1280, [int] $Height = 720) { + $rgb = [byte[]]::new($Width * $Height * 3) + $pixel = 0 + for ($offset = 0; $offset -lt $rgb.Length; $offset += 3) { + if ($pixel % 2 -eq 0) { + $rgb[$offset] = 0x11 + $rgb[$offset + 1] = 0x22 + $rgb[$offset + 2] = 0x33 + } else { + $rgb[$offset] = 0x44 + $rgb[$offset + 1] = 0x55 + $rgb[$offset + 2] = 0x66 + } + $pixel++ + } + return (Get-TestHash $rgb) +} + +function Get-TestFileHash([string] $Path) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() +} + +function Get-ContractSourceHashes { + $hashes = [ordered]@{} + foreach ($relativePath in $sourcePaths) { + $hashes[$relativePath] = Get-TestFileHash (Join-Path $repoRoot $relativePath) + } + return $hashes +} + +function Get-SyntheticArtifactFact([string] $Path) { + if (Test-Path -LiteralPath $Path -PathType Leaf) { + return [ordered]@{ + path = $Path + present = $true + sha256 = (Get-TestFileHash $Path) + } + } + return [ordered]@{ + path = $Path + present = $false + sha256 = $null + } +} + +function Update-SyntheticArtifactHash([object] $Run, [string] $Name) { + $runReceipt = Get-Content -LiteralPath $Run.RunReceiptPath -Raw | ConvertFrom-Json -AsHashtable + $path = switch ($Name) { + "testBinary" { $Run.TestBinaryPath } + "nativeLog" { $Run.NativeLogPath } + "paintReceipt" { $Run.ReceiptPath } + default { throw "Unknown synthetic native artifact: $Name" } + } + $runReceipt.nativeArtifacts[$Name] = Get-SyntheticArtifactFact $path + [IO.File]::WriteAllText( + $Run.RunReceiptPath, + ($runReceipt | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) +} + +function New-Receipt([string] $TransportPath, [string] $RgbHash, [string] $BmpHash, [string] $FixtureDigest) { + $receipt = [ordered]@{ + schemaVersion = 1 + evidenceClass = "paint-only" + scene = "project-paint-v1" + fixturePath = "graphcode-windows/fixtures/paint-project-v1.json" + fixtureSha256 = $FixtureDigest + sourceCommit = "0000000000000000000000000000000000000000" + renderer = "GraphCanvas.paint + GraphCanvas.paintHeader -> memory DIB" + dimensions = [ordered]@{ width = 1280; height = 720; logicalWidth = 1280; logicalHeight = 720 } + dpiMapping = [ordered]@{ windowExtX = 96; windowExtY = 96; viewportExtX = 96; viewportExtY = 96; deviceDpiX = 96; deviceDpiY = 96 } + canvas = [ordered]@{ zoom = 1; panX = 0; panY = 0; selectedIndex = 0; railVisible = $true; panelVisible = $false; activityEnabled = $false } + model = [ordered]@{ nodes = 2; edges = 1; activityHistory = 0; createdAtValues = 0 } + header = [ordered]@{ title = "GraphCode"; context = "Alpha"; reachabilityClaim = "declared fixture/test value only; not App reachability" } + gdiPlusReady = $true + colorAssumption = "SDR RGB; unused DIB alpha is normalized to opaque in BMP transport" + fontEvidenceScope = "Sampled card title, state, metadata, and header title font selections only; not a census of every text draw" + fonts = @( + [ordered]@{ role = "card-title"; logicalFace = "Segoe UI"; actualFace = "Segoe UI"; height = -15; weight = 600; quality = "CLEARTYPE_QUALITY" }, + [ordered]@{ role = "card-state"; logicalFace = "Segoe UI"; actualFace = "Segoe UI"; height = -10; weight = 700; quality = "CLEARTYPE_QUALITY" }, + [ordered]@{ role = "card-mono"; logicalFace = "Consolas"; actualFace = "Consolas"; height = -11; weight = 400; quality = "CLEARTYPE_QUALITY" }, + [ordered]@{ role = "header"; logicalFace = "Segoe UI"; actualFace = "Segoe UI"; height = -11; weight = 400; quality = "CLEARTYPE_QUALITY" } + ) + transport = @( + [ordered]@{ name = "project-paint-v1.bmp"; bmpSha256 = $BmpHash; rgbSha256 = $RgbHash }, + [ordered]@{ name = "project-paint-v1-repeat-2.bmp"; rgbSha256 = $RgbHash }, + [ordered]@{ name = "project-paint-v1-repeat-3.bmp"; rgbSha256 = $RgbHash } + ) + renderRgbSha256 = @($RgbHash, $RgbHash, $RgbHash) + candidateArtifactCount = 1 + excludedSurfaces = @("whole window", "composition", "popups", "terminals", "per-monitor DPI", "sidebar/right-panel fidelity", "native form controls", "accessibility") + } + $receiptPath = Join-Path $TransportPath "paint-receipt.json" + [IO.File]::WriteAllText($receiptPath, ($receipt | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + return $receiptPath +} + +function New-SyntheticRun([string] $Name, [int] $Width = 1280, [int] $Height = 720) { + $runRoot = Join-Path $scratchFull $Name + $transportRoot = Join-Path $runRoot "export\transport" + New-Item -ItemType Directory -Path $transportRoot -Force | Out-Null + $testBinary = Join-Path $runRoot "canvas-paint-tests.exe" + [IO.File]::WriteAllBytes($testBinary, [Text.Encoding]::ASCII.GetBytes("synthetic test binary identity")) + $nativeLog = Join-Path $runRoot "native.log" + [IO.File]::WriteAllText( + $nativeLog, + "Synthetic codec fixture only; no product scene.`n" + + "1/1 GraphCanvas.test.paint export: project-paint-v1...synthetic contract evidence`nOK`nAll 1 tests passed.`n") + $canonical = Join-Path $transportRoot "project-paint-v1.bmp" + if ($Width -eq 1280 -and $Height -eq 720) { + Copy-Item -LiteralPath $syntheticFixture -Destination $canonical + } else { + New-SyntheticBmp $canonical $Width $Height + } + Copy-Item -LiteralPath $canonical -Destination (Join-Path $transportRoot "project-paint-v1-repeat-2.bmp") + Copy-Item -LiteralPath $canonical -Destination (Join-Path $transportRoot "project-paint-v1-repeat-3.bmp") + $rgbHash = $script:syntheticRgbHash + $bmpHash = Get-TestFileHash $canonical + $receipt = New-Receipt $transportRoot $rgbHash $bmpHash $fixtureHash + $runReceiptPath = Join-Path $runRoot "run-receipt.json" + $sourceHashes = Get-ContractSourceHashes + $command = '& $env:GRAPHCODE_ZIG0152 test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter "paint export: project-paint-v1" -femit-bin=' + $testBinary + $runReceipt = [ordered]@{ + runId = $Name + command = $command + sourceHeadBefore = "0000000000000000000000000000000000000000" + sourceHeadAfter = "0000000000000000000000000000000000000000" + sourceHashesBefore = $sourceHashes + sourceHashesAfter = $sourceHashes + nativeExitCode = 0 + exportDirectory = (Join-Path $runRoot "export") + emittedTestBinary = $testBinary + log = $nativeLog + nativeArtifacts = [ordered]@{ + capturedImmediatelyAfterNativeCommand = $true + testBinary = (Get-SyntheticArtifactFact $testBinary) + nativeLog = (Get-SyntheticArtifactFact $nativeLog) + paintReceipt = (Get-SyntheticArtifactFact $receipt) + } + environmentFile = "synthetic contract input" + completedAt = [DateTimeOffset]::Now.ToString("o") + } + [IO.File]::WriteAllText($runReceiptPath, ($runReceipt | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + return [pscustomobject]@{ + RunRoot = $runRoot + TransportRoot = $transportRoot + InputBmp = $canonical + ReceiptPath = $receipt + RunReceiptPath = $runReceiptPath + TestBinaryPath = $testBinary + NativeLogPath = $nativeLog + OutputDirectory = Join-Path $runRoot "published" + } +} + +New-SyntheticBmp $syntheticFixture +$script:syntheticRgbHash = New-SyntheticRgbHash + +function Invoke-Converter([object] $Run) { + $arguments = @{ + InputBmp = $Run.InputBmp + ReceiptPath = $Run.ReceiptPath + RunReceiptPath = $Run.RunReceiptPath + TestBinaryPath = $Run.TestBinaryPath + OutputDirectory = $Run.OutputDirectory + } + if ($Run.TestPngCodecWriteFailure) { + $arguments.TestPngCodecWriteFailure = $true + } + return @(& $converter @arguments) +} + +function Assert-ConverterFailure([object] $Run, [string] $Code) { + $outputExisted = Test-Path -LiteralPath $Run.OutputDirectory + $failure = $null + try { + $null = Invoke-Converter $Run + } catch { + $failure = $_.Exception + } + Assert-Contract ($null -ne $failure) "Converter unexpectedly succeeded; expected $Code." + $message = $failure.Message + Assert-Contract $message.Contains("CanvasPaintArtifact[$Code]") ` + "Converter failed for the wrong reason; expected $Code, got: $message" + Assert-Contract (-not (Test-Path -LiteralPath (Join-Path $Run.OutputDirectory "paint-only-manifest.json"))) ` + "A failed conversion must not publish a success manifest." + if (-not $outputExisted) { + Assert-Contract (-not (Test-Path -LiteralPath $Run.OutputDirectory)) ` + "A failed conversion must not expose a partial output directory." + } + if ($Code -eq "OutputPublishFailed") { + $staging = @(Get-ChildItem -LiteralPath $Run.RunRoot -Directory -Filter ".canvas-paint-staging-*") + Assert-Contract ($staging.Count -eq 1) "A failed publication must preserve its staging directory." + Assert-Contract ((Test-Path -LiteralPath (Join-Path $staging[0].FullName "project-paint-v1.png")) -and + (Test-Path -LiteralPath (Join-Path $staging[0].FullName "paint-only-manifest.json"))) ` + "The verified staging artifacts must be preserved after publication failure." + } + if ($Code -eq "CodecWriteFailed") { + $evidence = $failure.Data + Assert-Contract ($evidence["saveReturnedNormally"] -is [bool]) ` + "Codec-write failure must record whether Bitmap.Save returned normally." + Assert-Contract ($evidence["writeCallbackCount"] -is [long] -and + $evidence["writeCallbackCount"] -gt 0) ` + "Codec-write failure must record at least one real stream write callback." + Assert-Contract ($evidence["partialBytesWritten"] -is [long] -and + $evidence["partialBytesWritten"] -gt 0) ` + "Codec-write failure must record bytes successfully written before the injected failure." + Assert-Contract ($evidence["injectedIOExceptionObserved"] -is [bool] -and + $evidence["injectedIOExceptionObserved"]) ` + "Codec-write failure must record the injected IOException at the stream boundary." + $injectedIOException = $evidence["injectedIOException"] + Assert-Contract ($injectedIOException -is [IO.IOException] -and + -not [string]::IsNullOrWhiteSpace($injectedIOException.StackTrace)) ` + "Codec-write failure must retain the injected IOException, stack, and HRESULT." + Assert-Contract ($failure.InnerException -is [System.Exception]) ` + "Codec-write failure must preserve its original exception chain." + Assert-Contract ($message.Contains(("HRESULT=0x{0:X8}" -f $injectedIOException.HResult))) ` + "Codec-write failure details must preserve the injected IOException HRESULT." + $saveException = $evidence["saveException"] + if ($evidence["saveReturnedNormally"]) { + Assert-Contract ($null -eq $saveException) ` + "A normally returning Bitmap.Save must not record a propagated Save exception." + } else { + Assert-Contract ($saveException -is [System.Exception] -and + -not [string]::IsNullOrWhiteSpace($saveException.ToString())) ` + "A propagated Bitmap.Save exception must retain its original exception details." + Assert-Contract ($message.Contains(("Propagated Bitmap.Save exception HRESULT=0x{0:X8}" -f + $saveException.HResult))) ` + "Codec-write failure details must preserve the propagated Bitmap.Save HRESULT." + } + $flushException = $evidence["flushException"] + if ($null -ne $flushException) { + Assert-Contract ($flushException -is [System.Exception] -and + -not [string]::IsNullOrWhiteSpace($flushException.ToString())) ` + "A propagated stream flush exception must retain its original exception details." + Assert-Contract ($message.Contains(("Propagated stream flush exception HRESULT=0x{0:X8}" -f + $flushException.HResult))) ` + "Codec-write failure details must preserve the propagated stream flush HRESULT." + } + $propagatedException = $evidence["propagatedException"] + if ($null -ne $propagatedException) { + Assert-Contract ($propagatedException -is [System.Exception] -and + -not [string]::IsNullOrWhiteSpace($propagatedException.ToString())) ` + "A propagated Save/flush exception must retain its original exception details." + } + Write-Host "CODEC_WRITE_EVIDENCE saveReturnedNormally=$($evidence['saveReturnedNormally']) callbacks=$($evidence['writeCallbackCount']) partialBytes=$($evidence['partialBytesWritten'])" + Write-Host "Injected IOException evidence:`n$($injectedIOException.ToString())" + if ($null -ne $propagatedException) { + Write-Host "Propagated Save/flush exception evidence:`n$($propagatedException.ToString())" + } + Write-Host "Wrapped converter exception chain:`n$($failure.ToString())" + $staging = @(Get-ChildItem -LiteralPath $Run.RunRoot -Directory -Filter ".canvas-paint-staging-*") + Assert-Contract ($staging.Count -eq 1) "A PNG codec write failure must preserve its staging directory." + $partialPng = Join-Path $staging[0].FullName "project-paint-v1.png" + Assert-Contract ((Test-Path -LiteralPath $partialPng -PathType Leaf) -and + (Get-Item -LiteralPath $partialPng).Length -gt 0) ` + "The actual PNG codec must partially write its staging file before the injected stream failure." + Assert-Contract (-not (Test-Path -LiteralPath (Join-Path $staging[0].FullName "paint-only-manifest.json"))) ` + "A PNG codec write failure must not create a success manifest." + } +} + +function Invoke-ContractCase([string] $Name, [scriptblock] $Body) { + try { + & $Body + $caseResults.Add([pscustomobject]@{ Name = $Name; Passed = $true; Error = $null }) + Write-Host "PASS $Name" + } catch { + $caseResults.Add([pscustomobject]@{ Name = $Name; Passed = $false; Error = $_.Exception.Message }) + Write-Host "FAIL $Name :: $($_.Exception.Message)" + } +} + +Invoke-ContractCase "valid round-trip" { + $run = New-SyntheticRun "valid" + $result = @(Invoke-Converter $run) + Assert-Contract ($result.Count -eq 1) "Valid conversion must return exactly one result." + Assert-Contract ($result[0].ArtifactCount -eq 1) "Valid conversion must publish exactly one PNG." + Assert-Contract (Test-Path -LiteralPath $result[0].Artifact -PathType Leaf) "PNG output is missing." + $manifest = Get-Content -LiteralPath $result[0].Manifest -Raw | ConvertFrom-Json + Assert-Contract ($manifest.evidenceClass -ceq "real paint path, offscreen component render") ` + "Manifest evidence class is incorrect." + Assert-Contract ($manifest.nativeRun.nativeExitCode -eq 0 -and + $manifest.nativeRun.testSummary -ceq "All 1 tests passed.") ` + "Manifest must record a positive native-run result." + Assert-Contract ($manifest.nativeRun.runReceiptSha256 -ceq (Get-TestFileHash $run.RunReceiptPath)) ` + "Manifest must bind the exact parsed native run receipt bytes." + Assert-Contract ($manifest.nativeRun.runReceiptPath -ceq $run.RunReceiptPath) ` + "Manifest must identify the bound native run receipt." + Assert-Contract ($manifest.nativeRun.artifactHashes.capturedImmediatelyAfterNativeCommand -eq $true) ` + "Manifest must identify hashes captured immediately after the native command." + Assert-Contract ($manifest.nativeRun.artifactHashes.testBinarySha256 -ceq + (Get-TestFileHash $run.TestBinaryPath)) "Manifest must preserve the native-run test-binary hash." + Assert-Contract ($manifest.nativeRun.artifactHashes.nativeLogSha256 -ceq + (Get-TestFileHash $run.NativeLogPath)) "Manifest must preserve the native-run log hash." + Assert-Contract ($manifest.nativeRun.artifactHashes.paintReceiptSha256 -ceq + (Get-TestFileHash $run.ReceiptPath)) "Manifest must preserve the native-run paint-receipt hash." + Assert-Contract ($manifest.nativeRun.artifactHashes.identityScope -match "not cryptographic execution attestation") ` + "Manifest must not overstate native artifact association as execution attestation." + Assert-Contract ($manifest.environment.pinEvidenceScope -match "not measured by the converter") ` + "Manifest must label Zig and Winghostty values as repository pins, not measured process identity." + Assert-Contract ($manifest.paintReceipt.sha256 -ceq (Get-TestFileHash $run.ReceiptPath)) ` + "Manifest must bind the exact parsed paint receipt bytes." + Assert-Contract ($manifest.nativeLog.sha256 -ceq (Get-TestFileHash $run.NativeLogPath)) ` + "Manifest must bind the exact native test log bytes." + Assert-Contract ($manifest.fixture.sourceBaseCommit -ceq "0000000000000000000000000000000000000000") ` + "Fixture provenance must label sourceCommit as the native run's base HEAD." + Assert-Contract ($manifest.gdiPlusEvidenceScope -match "does not prove every drawing operation") ` + "Manifest must limit the GDI+ readiness claim." + Assert-Contract ($manifest.fontFileEvidenceScope -match "lookup candidates") ` + "Manifest must distinguish selected font-face evidence from font-file lookup candidates." + Assert-Contract (@($manifest.sourceFiles).Count -eq 7) ` + "Manifest must carry all seven source hashes from the native run receipt." + $currentSourceHashes = Get-ContractSourceHashes + foreach ($sourceFact in $manifest.sourceFiles) { + $relativePath = $sourceFact.path.Replace("/", "\") + Assert-Contract ($sourceFact.sha256 -ceq $currentSourceHashes[$relativePath]) ` + "Manifest source identity differs from the actual native-run source snapshot: $relativePath" + } + Assert-Contract ($manifest.artifact.width -eq 1280 -and $manifest.artifact.height -eq 720) ` + "PNG output geometry is incorrect." + $published = @(Get-ChildItem -LiteralPath $run.OutputDirectory -Force) + Assert-Contract ($published.Count -eq 2) "Output must contain exactly one PNG and one manifest." + $staging = @(Get-ChildItem -LiteralPath $run.RunRoot -Directory -Filter ".canvas-paint-staging-*") + Assert-Contract ($staging.Count -eq 0) "Successful publication must not leave a staging directory." +} + +Invoke-ContractCase "absent BMP" { + $run = New-SyntheticRun "absent" + Remove-Item -LiteralPath $run.InputBmp + Assert-ConverterFailure $run "InputBmpMissing" + + $failedLogRun = New-SyntheticRun "failed-native-log" + [IO.File]::WriteAllText( + $failedLogRun.NativeLogPath, + "1/1 GraphCanvas.test.paint export: project-paint-v1...FAIL`nAll 1 tests failed.`n") + Update-SyntheticArtifactHash $failedLogRun "nativeLog" + Assert-ConverterFailure $failedLogRun "NativeRunSummaryInvalid" + Write-Host "PASS subcontrol rejects a failed native test summary" + + $zeroRun = New-SyntheticRun "zero-native-tests" + [IO.File]::WriteAllText($zeroRun.NativeLogPath, "All 0 tests passed.`n") + Update-SyntheticArtifactHash $zeroRun "nativeLog" + Assert-ConverterFailure $zeroRun "NativeRunSummaryInvalid" + Write-Host "PASS subcontrol rejects a zero-test native summary" + + $skippedRun = New-SyntheticRun "skipped-native-test" + [IO.File]::WriteAllText( + $skippedRun.NativeLogPath, + "1/1 GraphCanvas.test.paint export: project-paint-v1...SKIP`nAll 1 tests skipped.`n") + Update-SyntheticArtifactHash $skippedRun "nativeLog" + Assert-ConverterFailure $skippedRun "NativeRunSummaryInvalid" + Write-Host "PASS subcontrol rejects a skipped native test" + + $failedExitRun = New-SyntheticRun "failed-native-exit" + $failedExitReceipt = Get-Content -LiteralPath $failedExitRun.RunReceiptPath -Raw | ConvertFrom-Json -AsHashtable + $failedExitReceipt.nativeExitCode = 1 + [IO.File]::WriteAllText($failedExitRun.RunReceiptPath, ($failedExitReceipt | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + Assert-ConverterFailure $failedExitRun "NativeRunFailed" + Write-Host "PASS subcontrol rejects a nonzero native exit" + + $logMismatchRun = New-SyntheticRun "native-log-path-mismatch" + $logMismatchReceipt = Get-Content -LiteralPath $logMismatchRun.RunReceiptPath -Raw | ConvertFrom-Json -AsHashtable + $logMismatchReceipt.log = Join-Path $logMismatchRun.RunRoot "other.log" + [IO.File]::WriteAllText( + $logMismatchRun.RunReceiptPath, + ($logMismatchReceipt | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + Assert-ConverterFailure $logMismatchRun "NativeRunReceiptInvalid" + Write-Host "PASS subcontrol rejects a native log path mismatch" + + $binarySubstitutionRun = New-SyntheticRun "substituted-test-binary" + [IO.File]::WriteAllBytes( + $binarySubstitutionRun.TestBinaryPath, + [Text.Encoding]::ASCII.GetBytes("substituted test binary bytes")) + Assert-ConverterFailure $binarySubstitutionRun "NativeTestBinaryHashMismatch" + Write-Host "PASS subcontrol rejects post-run test-binary substitution" + + $logSubstitutionRun = New-SyntheticRun "substituted-native-log" + [IO.File]::AppendAllText($logSubstitutionRun.NativeLogPath, "`n") + Assert-ConverterFailure $logSubstitutionRun "NativeLogHashMismatch" + Write-Host "PASS subcontrol rejects post-run native-log substitution" + + $paintReceiptSubstitutionRun = New-SyntheticRun "substituted-paint-receipt" + [IO.File]::AppendAllText($paintReceiptSubstitutionRun.ReceiptPath, "`n") + Assert-ConverterFailure $paintReceiptSubstitutionRun "NativePaintReceiptHashMismatch" + Write-Host "PASS subcontrol rejects post-run paint-receipt substitution" +} + +Invoke-ContractCase "truncated BMP" { + $run = New-SyntheticRun "truncated" + [IO.File]::WriteAllBytes($run.InputBmp, [byte[]]@(0x42, 0x4D, 0x00)) + Assert-ConverterFailure $run "TruncatedBmp" +} + +Invoke-ContractCase "wrong geometry" { + $run = New-SyntheticRun "wrong-geometry" 1280 719 + Assert-ConverterFailure $run "WrongGeometry" +} + +Invoke-ContractCase "fixture hash mismatch" { + $run = New-SyntheticRun "fixture-mismatch" + $receipt = Get-Content -LiteralPath $run.ReceiptPath -Raw | ConvertFrom-Json -AsHashtable + $receipt.fixtureSha256 = "0000000000000000000000000000000000000000000000000000000000000000" + [IO.File]::WriteAllText($run.ReceiptPath, ($receipt | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false)) + Update-SyntheticArtifactHash $run "paintReceipt" + Assert-ConverterFailure $run "FixtureHashMismatch" + + $staleSourceRun = New-SyntheticRun "stale-native-source" + $staleSourceReceipt = Get-Content -LiteralPath $staleSourceRun.RunReceiptPath -Raw | ConvertFrom-Json -AsHashtable + $staleHash = "0000000000000000000000000000000000000000000000000000000000000000" + $staleSourceReceipt.sourceHashesBefore["graphcode-windows\src\GraphCanvas.zig"] = $staleHash + $staleSourceReceipt.sourceHashesAfter["graphcode-windows\src\GraphCanvas.zig"] = $staleHash + [IO.File]::WriteAllText( + $staleSourceRun.RunReceiptPath, + ($staleSourceReceipt | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + Assert-ConverterFailure $staleSourceRun "NativeSourceHashMismatch" + Write-Host "PASS subcontrol rejects a stale native source hash" + + $changedDuringRun = New-SyntheticRun "source-changed-during-run" + $changedReceipt = Get-Content -LiteralPath $changedDuringRun.RunReceiptPath -Raw | ConvertFrom-Json -AsHashtable + $changedReceipt.sourceHashesBefore["graphcode-windows\src\GraphCanvas.zig"] = $staleHash + [IO.File]::WriteAllText( + $changedDuringRun.RunReceiptPath, + ($changedReceipt | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + Assert-ConverterFailure $changedDuringRun "NativeSourceChangedDuringRun" + Write-Host "PASS subcontrol rejects source changes during native execution" +} + +Invoke-ContractCase "existing foreign output" { + $overlappingRun = New-SyntheticRun "transport-output" + $overlappingRun.OutputDirectory = Join-Path $overlappingRun.TransportRoot "published" + Assert-ConverterFailure $overlappingRun "OutputPathOverlapsTransport" + + $run = New-SyntheticRun "foreign-output" + New-Item -ItemType Directory -Path $run.OutputDirectory | Out-Null + [IO.File]::WriteAllText((Join-Path $run.OutputDirectory "foreign.txt"), "preserve") + Assert-ConverterFailure $run "OutputNotFresh" + Assert-Contract (Test-Path -LiteralPath (Join-Path $run.OutputDirectory "foreign.txt")) ` + "Converter must preserve foreign output." +} + +Invoke-ContractCase "reparse output" { + $run = New-SyntheticRun "reparse-output" + New-Item -ItemType Junction -Path $run.OutputDirectory -Target $run.RunRoot | Out-Null + Assert-ConverterFailure $run "ReparsePoint" +} + +Invoke-ContractCase "codec/write failure" { + $run = New-SyntheticRun "codec-write-failure" + $run | Add-Member -NotePropertyName TestPngCodecWriteFailure -NotePropertyValue $true + Assert-ConverterFailure $run "CodecWriteFailed" + Write-Host "PASS subcontrol fails inside Bitmap.Save after a partial staging-file write" + + $publishRun = New-SyntheticRun "publish-failure" + $blocker = Join-Path $publishRun.RunRoot "not-a-directory" + [IO.File]::WriteAllText($blocker, "output parent blocker") + $publishRun.OutputDirectory = Join-Path $blocker "published" + Assert-ConverterFailure $publishRun "OutputPublishFailed" + Write-Host "PASS subcontrol preserves verified staging after final publication failure" +} + +$expectedNames = @( + "valid round-trip", + "absent BMP", + "truncated BMP", + "wrong geometry", + "fixture hash mismatch", + "existing foreign output", + "reparse output", + "codec/write failure" +) +$names = @($caseResults | ForEach-Object { $_.Name }) +$uniqueNames = @($names | Sort-Object -Unique) +$passed = @($caseResults | Where-Object Passed).Count +$failed = $caseResults.Count - $passed +$skipped = 0 +$namesMatch = ($names.Count -eq $expectedNames.Count) -and + (($names -join "`n") -ceq ($expectedNames -join "`n")) -and + ($uniqueNames.Count -eq $expectedNames.Count) +$summary = [pscustomobject]@{ + Executed = [int]$caseResults.Count + Passed = [int]$passed + Failed = [int]$failed + Skipped = [int]$skipped + CaseNames = [string[]]$names +} +Write-Host "CANVAS_PAINT_CASES executed=$($summary.Executed) passed=$($summary.Passed) skipped=$($summary.Skipped) failed=$($summary.Failed) names=$($names -join '|')" +Write-Output $summary +if (-not $namesMatch -or $summary.Executed -ne 8 -or $summary.Passed -ne 8 -or + $summary.Skipped -ne 0 -or $summary.Failed -ne 0) { + foreach ($failure in $caseResults | Where-Object { -not $_.Passed }) { + Write-Error "Contract $($failure.Name) failed: $($failure.Error)" -ErrorAction Continue + } + throw "Canvas paint artifact contracts did not execute the exact 8/8/0/0 suite." +} diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index f594dffa..5187ae06 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -1029,7 +1029,7 @@ Invoke-Native "Graph canvas executable tests" { } Push-Location $shellRoot try { - & $zig test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" + & $zig test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$include" } finally { Pop-Location } } @@ -1159,9 +1159,42 @@ Invoke-Native "GDI+ antialiasing executable tests" { $include = Join-Path $winghosttyRoot "include" Push-Location $shellRoot try { - & $zig test src\GdiplusAA.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" + & $zig test src\GdiplusAA.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$include" } finally { Pop-Location } } +Invoke-Native "Canvas paint artifact codec contracts" { + $contractScript = Join-Path $repoRoot "Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1" + $scratchRoot = Join-Path ([IO.Path]::GetTempPath()) ("graphcode-canvas-paint-contracts-" + [guid]::NewGuid().ToString("N")) + $results = @(& $contractScript -ScratchRoot $scratchRoot) + $summaries = @($results | Where-Object { + $_ -is [pscustomobject] -and + $_.PSObject.Properties.Name -contains "Executed" -and + $_.PSObject.Properties.Name -contains "CaseNames" + }) + Assert-Contract ($summaries.Count -eq 1) ` + "canvas paint converter contracts must return exactly one typed summary" + $summary = $summaries[0] + $expectedNames = @( + "valid round-trip", + "absent BMP", + "truncated BMP", + "wrong geometry", + "fixture hash mismatch", + "existing foreign output", + "reparse output", + "codec/write failure" + ) + $actualNames = @($summary.CaseNames) + $uniqueNames = @($actualNames | Sort-Object -Unique) + Assert-Contract ($summary.Executed -is [int] -and $summary.Passed -is [int] -and + $summary.Failed -is [int] -and $summary.Skipped -is [int] -and + $summary.Executed -eq 8 -and $summary.Passed -eq 8 -and + $summary.Failed -eq 0 -and $summary.Skipped -eq 0) ` + "canvas paint converter contracts must report typed executed=8 passed=8 failed=0 skipped=0" + Assert-Contract ($actualNames.Count -eq 8 -and $uniqueNames.Count -eq 8 -and + ($actualNames -join "`n") -ceq ($expectedNames -join "`n")) ` + "canvas paint converter contracts must report the exact eight unique case names" +} Invoke-Native "Update offer dialog executable tests" { $depotRoot = Split-Path (Split-Path $repoRoot -Parent) -Parent $winghosttyRoot = [Environment]::GetEnvironmentVariable("GRAPHCODE_WINGHOSTTY_ROOT") diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index b87c028a..26c401f8 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -844,6 +844,163 @@ Keep claims aligned with the [Windows UI parity ledger](../investigation/ui-parity-matrix.md) and the [preview-readiness plan](../investigation/windows-preview-release-plan.md). +### Offscreen canvas paint artifact + +The opt-in `paint export: project-paint-v1` Zig test calls the production +`GraphCanvas.paint` and `paintHeader` functions into three fresh 1280x720 +memory DIBs, then writes BMP transport files and a paint receipt. It runs only +when `GRAPHCODE_PAINT_EXPORT_DIR` and `GRAPHCODE_PAINT_SOURCE_COMMIT` are set; +the export directory must be fresh and empty. From a bootstrapped checkout: + +Run the entire procedure from PowerShell 7 (`pwsh`), not Windows PowerShell +5.1; the converter requires PowerShell 7's JSON and hash APIs. + +```powershell +. .\.graphcode-tools\environment.ps1 +$runRoot = Join-Path $env:TEMP ("graphcode-paint-" + [guid]::NewGuid().ToString("N")) +$exportRoot = Join-Path $runRoot "export" +$tempRoot = Join-Path $runRoot "temp" +if (Test-Path -LiteralPath $runRoot) { throw "Refusing existing paint run root: $runRoot" } +New-Item -ItemType Directory -Path $runRoot | Out-Null +New-Item -ItemType Directory -Path $exportRoot, $tempRoot | Out-Null +$testBinary = Join-Path $runRoot "canvas-paint-tests.exe" +$nativeLog = Join-Path $runRoot "native.log" +$runReceiptPath = Join-Path $runRoot "run-receipt.json" +$environmentFile = (Resolve-Path .\.graphcode-tools\environment.ps1).Path +$sourcePaths = @( + "graphcode-windows\src\GraphCanvas.zig", + "graphcode-windows\src\GdiplusAA.zig", + "graphcode-windows\fixtures\paint-project-v1.json", + "Tools\windows\Convert-CanvasPaintArtifact.ps1", + "Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1", + "Tools\windows\Tests\WindowsShell.Tests.ps1", + "graphcode-windows\README.md" +) +function Get-PaintSourceHashes { + $hashes = [ordered]@{} + foreach ($relativePath in $sourcePaths) { + $path = Join-Path (Get-Location).Path $relativePath + $hashes[$relativePath] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() + } + return $hashes +} +function Get-PaintArtifactFact([string] $Path) { + if (Test-Path -LiteralPath $Path -PathType Leaf) { + return [ordered]@{ + path = $Path + present = $true + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + } + } + return [ordered]@{ path = $Path; present = $false; sha256 = $null } +} +$nativeArtifacts = $null +$sourceHeadBefore = (git rev-parse HEAD).Trim() +$sourceHashesBefore = Get-PaintSourceHashes +$env:GRAPHCODE_PAINT_EXPORT_DIR = $exportRoot +$env:GRAPHCODE_PAINT_SOURCE_COMMIT = $sourceHeadBefore +$env:TEMP = $tempRoot +$env:TMP = $tempRoot +$command = '& $env:GRAPHCODE_ZIG0152 test src\GraphCanvas.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter "paint export: project-paint-v1" --cache-dir ' + + (Join-Path $runRoot "zig-local") + ' --global-cache-dir ' + (Join-Path $runRoot "zig-global") + + ' -femit-bin="' + $testBinary + '"' +$nativeExitCode = $null +$locationPushed = $false +Push-Location graphcode-windows +$locationPushed = $true +try { + & $env:GRAPHCODE_ZIG0152 test src\GraphCanvas.zig ` + -target x86_64-windows-msvc -lc -luser32 -lgdi32 -lgdiplus ` + "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" ` + --test-filter "paint export: project-paint-v1" ` + --cache-dir (Join-Path $runRoot "zig-local") ` + --global-cache-dir (Join-Path $runRoot "zig-global") ` + ("-femit-bin=" + $testBinary) *>&1 | Tee-Object -FilePath $nativeLog + $nativeExitCode = $LASTEXITCODE + $nativeArtifacts = [ordered]@{ + capturedImmediatelyAfterNativeCommand = $true + testBinary = (Get-PaintArtifactFact $testBinary) + nativeLog = (Get-PaintArtifactFact $nativeLog) + paintReceipt = (Get-PaintArtifactFact (Join-Path $exportRoot "transport\paint-receipt.json")) + } +} finally { + if ($locationPushed) { Pop-Location } + if ($null -eq $nativeArtifacts) { + $nativeArtifacts = [ordered]@{ + capturedImmediatelyAfterNativeCommand = $true + testBinary = (Get-PaintArtifactFact $testBinary) + nativeLog = (Get-PaintArtifactFact $nativeLog) + paintReceipt = (Get-PaintArtifactFact (Join-Path $exportRoot "transport\paint-receipt.json")) + } + } + $sourceHeadAfter = (git rev-parse HEAD).Trim() + $sourceHashesAfter = Get-PaintSourceHashes + [ordered]@{ + runId = Split-Path -Leaf $runRoot + command = $command + sourceHeadBefore = $sourceHeadBefore + sourceHeadAfter = $sourceHeadAfter + sourceHashesBefore = $sourceHashesBefore + sourceHashesAfter = $sourceHashesAfter + nativeExitCode = $nativeExitCode + exportDirectory = $exportRoot + emittedTestBinary = $testBinary + log = $nativeLog + nativeArtifacts = $nativeArtifacts + environmentFile = $environmentFile + completedAt = [DateTimeOffset]::Now.ToString("o") + } | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath $runReceiptPath -Encoding utf8 +} +if ($nativeExitCode -ne 0) { throw "Offscreen paint test failed: $nativeExitCode" } +pwsh -NoProfile -File Tools\windows\Convert-CanvasPaintArtifact.ps1 ` + -InputBmp (Join-Path $exportRoot "transport\project-paint-v1.bmp") ` + -ReceiptPath (Join-Path $exportRoot "transport\paint-receipt.json") ` + -RunReceiptPath $runReceiptPath ` + -TestBinaryPath $testBinary ` + -OutputDirectory (Join-Path $runRoot "published") +``` + +The converter parses and hashes the same frozen run receipt, paint receipt, +and native log bytes. It requires a successful native exit, the exact named +1/1 paint-test result, stable hashes for all seven leased files before and +after the run, and current file hashes matching that native-run snapshot. +Immediately after the native command, the wrapper records whether the emitted +test binary, native log, and paint receipt exist and, when present, their +SHA-256 values. Missing files remain explicitly marked missing; successful +conversion requires all three hashes to match the exact bytes consumed by the +converter. `sourceBaseCommit` identifies the run's base HEAD only; the measured +source hashes bind the seven leased files, not a clean-tree snapshot of other +imported modules or their dirty/untracked state. A stale run receipt cannot be +converted after those source or native-artifact bytes change. The converter +then performs a lossless BMP-to-PNG round trip, verifies decoded RGB against +the native render, and atomically publishes one PNG with a paint-only +provenance manifest. Failed staging artifacts remain under the run root for +diagnosis; they are never exposed at the final output path. The immediate +hashes establish byte association between the run receipt and conversion +inputs, not cryptographic proof that the same executable produced the render; +transport BMP hashes and decoded RGB are checked against the bound native +paint receipt. GDI+ readiness proves initialization only, not that every +drawing operation used GDI+ rather than the existing GDI +fallback. Font evidence samples the card-title, card-state, card-metadata, +and header-title selections; it is not a census of every text draw. Font-file +facts are lookup candidates, not proof of the physical font file selected by +GDI. The converter's eight contract cases use synthetic codec BMPs and +receipts, not a product paint scene; they include refusal controls for +post-run test-binary, native-log, and paint-receipt substitutions. The +codec/write case calls the real PNG encoder with a stream that allows a +partial staging-file write and then fails; it separately covers final +publication failure. They run with: +`pwsh -NoProfile -File Tools\windows\Tests\CanvasPaintArtifact.Tests.ps1 +-ScratchRoot `. The evidence class is **real paint +path, offscreen component render**. It can support canvas card, edge, panel, +and layout review only where those pixels are actually in-frame; this fixture +hides the right panel and cannot validate its content. It does not prove +whole-window composition, popups, terminals, native controls, accessibility, +or per-monitor DPI. The three +matching RGB hashes establish same-host/build/fixture repeatability only, not +a cross-machine golden. Artifacts are for local agent visual review; there is +no screenshot fallback, golden-image baseline, or CI visual gate. + ## Build From a fresh checkout, bootstrap the exact Zig toolchains, Swift 6.3.3, and diff --git a/graphcode-windows/fixtures/paint-project-v1.json b/graphcode-windows/fixtures/paint-project-v1.json new file mode 100644 index 00000000..3b6dd088 --- /dev/null +++ b/graphcode-windows/fixtures/paint-project-v1.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "kind": "event", + "sequence": 2, + "event": { + "graphChanged": { + "id": "project-paint-v1", + "project": { + "path": "C:\\GraphCodeFixture\\Alpha", + "name": "Alpha", + "remote": false + }, + "nodes": [ + { + "id": "paint-goal", + "title": "Goal", + "loopType": "goalBased", + "state": { + "idle": {} + }, + "backend": "copilotCLI", + "goal": { + "summary": "Keep README present." + } + }, + { + "id": "paint-check", + "title": "Check", + "loopType": "turnBased", + "state": { + "idle": {} + }, + "backend": "copilotCLI", + "checkDescription": "Check formatting." + } + ], + "edges": [ + { + "id": "paint-goal-to-check", + "from": "paint-goal", + "to": "paint-check", + "kind": "handoff", + "condition": "always", + "fired": false, + "fireCount": 0 + } + ] + } + } +} diff --git a/graphcode-windows/src/GdiplusAA.zig b/graphcode-windows/src/GdiplusAA.zig index fedb9da0..e4eaba73 100644 --- a/graphcode-windows/src/GdiplusAA.zig +++ b/graphcode-windows/src/GdiplusAA.zig @@ -71,6 +71,10 @@ var startup_token: usize = 0; var available: bool = false; var start_attempted: bool = false; var shutdown_requested: bool = false; +var readiness_test_entered: c.HANDLE = null; +var readiness_test_release: c.HANDLE = null; +var readiness_test_finished: c.HANDLE = null; +const readiness_test_timeout_ms: c.DWORD = 2_000; const SystemStartup = struct { pub fn run() ?usize { @@ -114,6 +118,12 @@ pub fn initWith(comptime Startup: type) void { worker.detach(); } +pub fn isReady() bool { + startup_mutex.lock(); + defer startup_mutex.unlock(); + return available; +} + /// Prevents new GDI+ sessions and releases a completed process-global startup. /// A worker still blocked inside Windows remains detached and cannot delay exit. pub fn deinit() void { @@ -263,3 +273,65 @@ test "colorrefToArgb preserves channel order" { // 0x007AB8FF is B=0x7A, G=0xB8, R=0xFF, so ARGB is 0xFF (alpha) FF (R) B8 (G) 7A (B). try std.testing.expectEqual(@as(u32, 0xFFFFB87A), colorrefToArgb(0x007AB8FF)); } + +const ReadinessGatedStartup = struct { + pub fn run() ?usize { + defer _ = c.SetEvent(readiness_test_finished); + if (c.SetEvent(readiness_test_entered) == 0) return null; + if (c.WaitForSingleObject(readiness_test_release, readiness_test_timeout_ms) != c.WAIT_OBJECT_0) { + return null; + } + return SystemStartup.run(); + } +}; + +test "readiness: gated native startup publishes only after release" { + const entered = c.CreateEventW(null, 1, 0, null) orelse return error.ReadinessEnteredEventCreationFailed; + var release: c.HANDLE = null; + var finished: c.HANDLE = null; + var handles_published = false; + var worker_may_run = false; + defer { + const worker_quiesced = if (worker_may_run) blk: { + _ = c.SetEvent(release); + deinit(); + break :blk c.WaitForSingleObject(finished, readiness_test_timeout_ms) == c.WAIT_OBJECT_0; + } else true; + if (worker_quiesced) { + if (handles_published) { + readiness_test_finished = null; + readiness_test_release = null; + readiness_test_entered = null; + } + if (finished) |handle| _ = c.CloseHandle(handle); + if (release) |handle| _ = c.CloseHandle(handle); + _ = c.CloseHandle(entered); + } + } + release = c.CreateEventW(null, 1, 0, null) orelse return error.ReadinessReleaseEventCreationFailed; + finished = c.CreateEventW(null, 1, 0, null) orelse return error.ReadinessFinishedEventCreationFailed; + + readiness_test_entered = entered; + readiness_test_release = release; + readiness_test_finished = finished; + handles_published = true; + worker_may_run = true; + initWith(ReadinessGatedStartup); + try std.testing.expectEqual( + c.WAIT_OBJECT_0, + c.WaitForSingleObject(entered, readiness_test_timeout_ms), + ); + try std.testing.expect(!isReady()); + if (c.SetEvent(release) == 0) return error.ReadinessReleaseFailed; + try std.testing.expectEqual( + c.WAIT_OBJECT_0, + c.WaitForSingleObject(finished, readiness_test_timeout_ms), + ); + + const deadline = std.time.nanoTimestamp() + 2_000_000_000; + while (!isReady()) { + if (std.time.nanoTimestamp() >= deadline) return error.ReadinessNativeStartupTimedOut; + std.Thread.sleep(1_000_000); + } + try std.testing.expect(isReady()); +} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 3c0285b7..1818c50b 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -3531,8 +3531,21 @@ const ParityDib = struct { dc: c.HDC, bitmap: c.HBITMAP, old_bitmap: c.HGDIOBJ, + pixels: [*]u8, + width: usize, + height: usize, + row_bytes: usize, + byte_len: usize, fn init(width: i32, height: i32) !ParityDib { + if (width <= 0 or height <= 0) return error.TestBitmapDimensionsInvalid; + const width_usize: usize = @intCast(width); + const height_usize: usize = @intCast(height); + if (width_usize > std.math.maxInt(usize) / 4) return error.TestBitmapDimensionsOverflow; + const row_bytes = width_usize * 4; + if (height_usize > std.math.maxInt(usize) / row_bytes) return error.TestBitmapDimensionsOverflow; + const byte_len = row_bytes * height_usize; + if (byte_len > std.math.maxInt(u32)) return error.TestBitmapTooLarge; const dc = c.CreateCompatibleDC(null) orelse return error.TestDcCreationFailed; errdefer _ = c.DeleteDC(dc); var info = std.mem.zeroes(c.BITMAPINFO); @@ -3542,9 +3555,23 @@ const ParityDib = struct { info.bmiHeader.biPlanes = 1; info.bmiHeader.biBitCount = 32; info.bmiHeader.biCompression = c.BI_RGB; + info.bmiHeader.biSizeImage = @intCast(byte_len); var bits: ?*anyopaque = null; const bitmap = c.CreateDIBSection(dc, &info, c.DIB_RGB_COLORS, &bits, null, 0) orelse return error.TestBitmapCreationFailed; - return .{ .dc = dc, .bitmap = bitmap, .old_bitmap = c.SelectObject(dc, bitmap) }; + errdefer _ = c.DeleteObject(bitmap); + const pixel_storage = bits orelse return error.TestBitmapBitsUnavailable; + const old_bitmap = c.SelectObject(dc, bitmap) orelse return error.TestBitmapSelectionFailed; + if (@intFromPtr(old_bitmap) == std.math.maxInt(usize)) return error.TestBitmapSelectionFailed; + return .{ + .dc = dc, + .bitmap = bitmap, + .old_bitmap = old_bitmap, + .pixels = @ptrCast(@alignCast(pixel_storage)), + .width = width_usize, + .height = height_usize, + .row_bytes = row_bytes, + .byte_len = byte_len, + }; } fn deinit(self: ParityDib) void { @@ -3564,8 +3591,457 @@ const ParityDib = struct { } return result; } + + fn rgbSha256(self: ParityDib, allocator: std.mem.Allocator) ![32]u8 { + if (c.GdiFlush() == 0) return error.PaintFixtureFlushFailed; + const pixel_count = self.width * self.height; + const rgb_len = pixel_count * 3; + const rgb = try allocator.alloc(u8, rgb_len); + defer allocator.free(rgb); + var destination: usize = 0; + for (0..self.height) |y| { + for (0..self.width) |x| { + const source = y * self.row_bytes + x * 4; + rgb[destination] = self.pixels[source + 2]; + rgb[destination + 1] = self.pixels[source + 1]; + rgb[destination + 2] = self.pixels[source]; + destination += 3; + } + } + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(rgb, &digest, .{}); + return digest; + } + + fn nonBackgroundPixels(self: ParityDib, colorref: u32) usize { + const red: u8 = @truncate(colorref); + const green: u8 = @truncate(colorref >> 8); + const blue: u8 = @truncate(colorref >> 16); + var result: usize = 0; + for (0..self.height) |y| { + for (0..self.width) |x| { + const offset = y * self.row_bytes + x * 4; + if (self.pixels[offset] != blue or self.pixels[offset + 1] != green or self.pixels[offset + 2] != red) + result += 1; + } + } + return result; + } + + fn writeBmp(self: ParityDib, allocator: std.mem.Allocator, path: []const u8) ![32]u8 { + if (c.GdiFlush() == 0) return error.PaintFixtureFlushFailed; + const header_len: usize = 54; + const file_len = header_len + self.byte_len; + if (file_len > std.math.maxInt(u32)) return error.TestBitmapTooLarge; + const bytes = try allocator.alloc(u8, file_len); + defer allocator.free(bytes); + @memset(bytes, 0); + bytes[0] = 'B'; + bytes[1] = 'M'; + putU32(bytes, 2, @intCast(file_len)); + putU32(bytes, 10, @intCast(header_len)); + putU32(bytes, 14, 40); + putU32(bytes, 18, @intCast(self.width)); + putU32(bytes, 22, @intCast(self.height)); + putU16(bytes, 26, 1); + putU16(bytes, 28, 32); + putU32(bytes, 30, c.BI_RGB); + putU32(bytes, 34, @intCast(self.byte_len)); + putU32(bytes, 38, 3780); + putU32(bytes, 42, 3780); + for (0..self.height) |file_y| { + const source_y = self.height - file_y - 1; + for (0..self.width) |x| { + const source = source_y * self.row_bytes + x * 4; + const destination = header_len + file_y * self.row_bytes + x * 4; + bytes[destination] = self.pixels[source]; + bytes[destination + 1] = self.pixels[source + 1]; + bytes[destination + 2] = self.pixels[source + 2]; + bytes[destination + 3] = 0xFF; + } + } + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + var file = try std.fs.cwd().createFile(path, .{ .exclusive = true }); + defer file.close(); + try file.writeAll(bytes); + return digest; + } +}; + +fn putU16(bytes: []u8, offset: usize, value: u16) void { + bytes[offset] = @truncate(value); + bytes[offset + 1] = @truncate(value >> 8); +} + +fn putU32(bytes: []u8, offset: usize, value: u32) void { + bytes[offset] = @truncate(value); + bytes[offset + 1] = @truncate(value >> 8); + bytes[offset + 2] = @truncate(value >> 16); + bytes[offset + 3] = @truncate(value >> 24); +} + +const PaintExportFixture = struct { + model: GraphModel.Model, + sha256: [std.crypto.hash.sha2.Sha256.digest_length]u8, +}; + +fn paintExportFixtureModel(allocator: std.mem.Allocator) !PaintExportFixture { + var model = GraphModel.Model.init(allocator); + errdefer model.deinit(); + const fixture = try std.fs.cwd().readFileAlloc(allocator, "fixtures\\paint-project-v1.json", 64 * 1024); + defer allocator.free(fixture); + var fixture_digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(fixture, &fixture_digest, .{}); + _ = try model.updateFromFrame(fixture); + if (!model.setSelectedIndex(0)) return error.PaintFixtureSelectionFailed; + const graph = model.graph orelse return error.PaintFixtureGraphMissing; + if (graph.nodes.items.len != 2 or graph.edges.items.len != 1 or model.activity.items.len != 0) + return error.PaintFixtureModelMismatch; + for (graph.nodes.items) |node| { + if (node.created_at != null) return error.PaintFixtureHasTimestamp; + if (!std.mem.eql(u8, node.state, "idle")) return error.PaintFixtureStateMismatch; + } + if (!std.mem.eql(u8, graph.nodes.items[0].title, "Goal") or + !std.mem.eql(u8, graph.nodes.items[1].title, "Check") or + !std.mem.eql(u8, graph.nodes.items[0].goal_summary, "Keep README present.") or + !std.mem.eql(u8, graph.nodes.items[1].check_description, "Check formatting.") or + !std.mem.eql(u8, graph.edges.items[0].condition, "always") or + !std.mem.eql(u8, graph.edges.items[0].kind, "handoff")) + { + return error.PaintFixtureTextMismatch; + } + return .{ .model = model, .sha256 = fixture_digest }; +} + +fn paintExportFixture(dib: ParityDib, model: *const GraphModel.Model, allocator: std.mem.Allocator) !void { + if (c.SetMapMode(dib.dc, c.MM_ANISOTROPIC) == 0) return error.PaintFixtureMapModeFailed; + if (c.SetWindowExtEx(dib.dc, 96, 96, null) == 0 or c.SetViewportExtEx(dib.dc, 96, 96, null) == 0) + return error.PaintFixtureDpiMappingFailed; + const state = CanvasState{ .zoom = 1, .pan_x = 0, .pan_y = 0 }; + var sidebar_state = Sidebar.State.init(allocator); + defer sidebar_state.deinit(); + const controls = WorkspaceControls.State{ + .graph_visible = true, + .rail_visible = true, + .panel_visible = false, + .activity_enabled = false, + }; + paint( + dib.dc, + 1280, + 720, + model, + null, + "", + 0, + "", + "", + "", + false, + "", + &.{}, + &.{}, + allocator, + &state, + &sidebar_state, + -1, + controls, + .project, + ); + paintHeader( + dib.dc, + allocator, + 1280, + "", + .{ .title = "GraphCode", .context = "Alpha", .panel_visible = false }, + null, + ); + if (c.GdiFlush() == 0) return error.PaintFixtureFlushFailed; +} + +const PaintFontFact = struct { + actual_face: []u8, + height: i32, + weight: i32, +}; + +fn inspectPaintFont( + allocator: std.mem.Allocator, + dc: c.HDC, + font: c.HFONT, + expected_face: []const u8, + expected_height: i32, + expected_weight: i32, +) !PaintFontFact { + const font_handle = font orelse return error.PaintFontCreationFailed; + var info = std.mem.zeroes(c.LOGFONTW); + if (c.GetObjectW(font_handle, @intCast(@sizeOf(c.LOGFONTW)), &info) != @as(c.INT, @intCast(@sizeOf(c.LOGFONTW)))) + return error.PaintFontInformationUnavailable; + if (info.lfHeight != expected_height or info.lfWeight != expected_weight or info.lfQuality != c.CLEARTYPE_QUALITY) + return error.PaintFontConfigurationMismatch; + + const previous = c.SelectObject(dc, font_handle) orelse return error.PaintFontSelectionFailed; + if (@intFromPtr(previous) == std.math.maxInt(usize)) return error.PaintFontSelectionFailed; + defer _ = c.SelectObject(dc, previous); + var face: [32]u16 = [_]u16{0} ** 32; + if (c.GetTextFaceW(dc, @intCast(face.len), &face) <= 0) return error.PaintFontFaceUnavailable; + const actual_face = try std.unicode.utf16LeToUtf8Alloc(allocator, std.mem.sliceTo(&face, 0)); + errdefer allocator.free(actual_face); + if (!std.mem.eql(u8, actual_face, expected_face)) return error.PaintFontSubstitution; + return .{ + .actual_face = actual_face, + .height = info.lfHeight, + .weight = info.lfWeight, + }; +} + +const PaintRenderProof = struct { + rgb_sha256: [32]u8, + bmp_sha256: [32]u8, + dpi_x: i32, + dpi_y: i32, }; +fn verifyPaintContract(dib: ParityDib, fixture: *const PaintExportFixture, allocator: std.mem.Allocator) !void { + const model = &fixture.model; + const graph = model.graph orelse return error.PaintFixtureGraphMissing; + const viewport = renderBounds(1280, 720, .{ + .rail_visible = true, + .panel_visible = false, + .activity_enabled = false, + }); + const state = CanvasState{ .zoom = 1, .pan_x = 0, .pan_y = 0 }; + for (graph.nodes.items, 0..) |node, index| { + const bounds = nodeBounds(index, &state); + try std.testing.expectEqual(@as(i32, 250), bounds.right - bounds.left); + try std.testing.expectEqual(@as(i32, 96), bounds.bottom - bounds.top); + try std.testing.expect(bounds.left >= viewport.left and bounds.top >= viewport.top); + try std.testing.expect(bounds.right <= viewport.right and bounds.bottom <= viewport.bottom); + const presentation = cardState(node); + const pill_width = blk: { + const old = try selectCardFont(dib.dc, 10, .card_state); + defer _ = c.SelectObject(dib.dc, old); + break :blk try measuredText(dib.dc, allocator, presentation.word) + 22; + }; + const rows = cardTextLayout(bounds, 1, pill_width); + try std.testing.expect(dib.count(rows.pill, presentation.fill) > 20); + try std.testing.expect(dib.count(rows.title, 0x00F4F4F4) > 0); + try std.testing.expect(dib.count(rows.live, 0x009F9C9C) > 0); + try std.testing.expectEqual(@as(usize, 36), dib.count(rows.meta, loopTypeColor(node.loop_type))); + try std.testing.expect(dib.count(rows.meta, blendCardColor(0x00FFFFFF, Tokens.loop_card_bottom, 52)) > 0); + } + try std.testing.expectEqual(@as(?usize, 0), model.selectedIndex()); + try std.testing.expectEqual(@as(usize, 0), model.activity.items.len); + const edge = graph.edges.items[0]; + const from = connectorPosition(graph.nodes.items, edge.from, true, &state).?; + const to = connectorPosition(graph.nodes.items, edge.to, false, &state).?; + const label_x = @divTrunc(from.x + to.x, 2); + const label_y = @min(from.y, to.y) - 76; + const label_bounds = rect(label_x - 74, label_y, label_x + 74, label_y + 20); + try std.testing.expect(label_bounds.left >= viewport.left and label_bounds.top >= viewport.top); + try std.testing.expect(label_bounds.right <= viewport.right and label_bounds.bottom <= viewport.bottom); + const label = try edgeLabel(allocator, edge); + defer allocator.free(label); + try std.testing.expectEqualStrings("handoff", label); + try std.testing.expect(dib.count(label_bounds, edgeKindColor(edge.kind)) > 0); + try std.testing.expect(dib.nonBackgroundPixels(Tokens.canvas_tone) > 10_000); +} + +fn renderPaintArtifact( + allocator: std.mem.Allocator, + fixture: *const PaintExportFixture, + path: []const u8, +) !PaintRenderProof { + var dib = try ParityDib.init(1280, 720); + defer dib.deinit(); + if (!GdiplusAA.isReady()) return error.PaintExportGdiplusNotReady; + try paintExportFixture(dib, &fixture.model, allocator); + try verifyPaintContract(dib, fixture, allocator); + const rgb_sha256 = try dib.rgbSha256(allocator); + const bmp_sha256 = try dib.writeBmp(allocator, path); + return .{ + .rgb_sha256 = rgb_sha256, + .bmp_sha256 = bmp_sha256, + .dpi_x = c.GetDeviceCaps(dib.dc, c.LOGPIXELSX), + .dpi_y = c.GetDeviceCaps(dib.dc, c.LOGPIXELSY), + }; +} + +fn writePaintReceipt( + allocator: std.mem.Allocator, + path: []const u8, + fixture: *const PaintExportFixture, + render: PaintRenderProof, + font_facts: *const [4]PaintFontFact, + hashes: *const [3][32]u8, + source_commit: []const u8, +) !void { + const fixture_hex = std.fmt.bytesToHex(fixture.sha256, .lower); + const bmp_hex = std.fmt.bytesToHex(render.bmp_sha256, .lower); + const rgb_hex = std.fmt.bytesToHex(render.rgb_sha256, .lower); + const render0_hex = std.fmt.bytesToHex(hashes[0], .lower); + const render1_hex = std.fmt.bytesToHex(hashes[1], .lower); + const render2_hex = std.fmt.bytesToHex(hashes[2], .lower); + const receipt = try std.fmt.allocPrint(allocator, + \\{{ + \\ "schemaVersion": 1, + \\ "evidenceClass": "paint-only", + \\ "scene": "project-paint-v1", + \\ "fixturePath": "graphcode-windows/fixtures/paint-project-v1.json", + \\ "fixtureSha256": "{s}", + \\ "sourceCommit": "{s}", + \\ "renderer": "GraphCanvas.paint + GraphCanvas.paintHeader -> memory DIB", + \\ "dimensions": {{"width": 1280, "height": 720, "logicalWidth": 1280, "logicalHeight": 720}}, + \\ "dpiMapping": {{"windowExtX": 96, "windowExtY": 96, "viewportExtX": 96, "viewportExtY": 96, "deviceDpiX": {d}, "deviceDpiY": {d}}}, + \\ "canvas": {{"zoom": 1, "panX": 0, "panY": 0, "selectedIndex": 0, "railVisible": true, "panelVisible": false, "activityEnabled": false}}, + \\ "model": {{"nodes": 2, "edges": 1, "activityHistory": 0, "createdAtValues": 0}}, + \\ "header": {{"title": "GraphCode", "context": "Alpha", "reachabilityClaim": "declared fixture/test value only; not App reachability"}}, + \\ "gdiPlusReady": true, + \\ "colorAssumption": "SDR RGB; unused DIB alpha is normalized to opaque in BMP transport", + \\ "fontEvidenceScope": "Sampled card title, state, metadata, and header title font selections only; not a census of every text draw", + \\ "fonts": [ + \\ {{"role": "card-title", "logicalFace": "Segoe UI", "actualFace": "{s}", "height": {d}, "weight": {d}, "quality": "CLEARTYPE_QUALITY"}}, + \\ {{"role": "card-state", "logicalFace": "Segoe UI", "actualFace": "{s}", "height": {d}, "weight": {d}, "quality": "CLEARTYPE_QUALITY"}}, + \\ {{"role": "card-mono", "logicalFace": "Consolas", "actualFace": "{s}", "height": {d}, "weight": {d}, "quality": "CLEARTYPE_QUALITY"}}, + \\ {{"role": "header", "logicalFace": "Segoe UI", "actualFace": "{s}", "height": {d}, "weight": {d}, "quality": "CLEARTYPE_QUALITY"}} + \\ ], + \\ "transport": [ + \\ {{"name": "project-paint-v1.bmp", "bmpSha256": "{s}", "rgbSha256": "{s}"}}, + \\ {{"name": "project-paint-v1-repeat-2.bmp", "rgbSha256": "{s}"}}, + \\ {{"name": "project-paint-v1-repeat-3.bmp", "rgbSha256": "{s}"}} + \\ ], + \\ "renderRgbSha256": ["{s}", "{s}", "{s}"], + \\ "candidateArtifactCount": 1, + \\ "excludedSurfaces": ["whole window", "composition", "popups", "terminals", "per-monitor DPI", "sidebar/right-panel fidelity", "native form controls", "accessibility"] + \\}} + , .{ + fixture_hex, + source_commit, + render.dpi_x, + render.dpi_y, + font_facts[0].actual_face, + font_facts[0].height, + font_facts[0].weight, + font_facts[1].actual_face, + font_facts[1].height, + font_facts[1].weight, + font_facts[2].actual_face, + font_facts[2].height, + font_facts[2].weight, + font_facts[3].actual_face, + font_facts[3].height, + font_facts[3].weight, + bmp_hex, + rgb_hex, + rgb_hex, + rgb_hex, + render0_hex, + render1_hex, + render2_hex, + }); + defer allocator.free(receipt); + var file = try std.fs.cwd().createFile(path, .{ .exclusive = true }); + defer file.close(); + try file.writeAll(receipt); +} + +test "paint export: project-paint-v1" { + const allocator = std.testing.allocator; + const export_root = std.process.getEnvVarOwned(allocator, "GRAPHCODE_PAINT_EXPORT_DIR") catch return error.SkipZigTest; + defer allocator.free(export_root); + if (export_root.len == 0) return error.PaintExportDirectoryEmpty; + + const transport = try std.fs.path.join(allocator, &.{ export_root, "transport" }); + defer allocator.free(transport); + var export_dir = try std.fs.cwd().openDir(export_root, .{ .iterate = true }); + defer export_dir.close(); + var iterator = export_dir.iterate(); + if (try iterator.next() != null) return error.PaintExportRootNotEmpty; + try std.fs.cwd().makeDir(transport); + + var fixture = try paintExportFixtureModel(allocator); + defer fixture.model.deinit(); + GdiplusAA.init(); + defer GdiplusAA.deinit(); + + const deadline = std.time.nanoTimestamp() + 2_000_000_000; + while (!GdiplusAA.isReady()) { + if (std.time.nanoTimestamp() >= deadline) return error.PaintExportGdiplusNotReady; + std.Thread.sleep(1_000_000); + } + + var font_facts: [4]PaintFontFact = undefined; + var font_facts_len: usize = 0; + defer for (font_facts[0..font_facts_len]) |fact| allocator.free(fact.actual_face); + const font_requests = [_]struct { + font: c.HFONT, + face: []const u8, + height: i32, + weight: i32, + }{ + .{ .font = AppFont.getStyle(14.5, .card_title), .face = "Segoe UI", .height = -15, .weight = c.FW_SEMIBOLD }, + .{ .font = AppFont.getStyle(10, .card_state), .face = "Segoe UI", .height = -10, .weight = c.FW_BOLD }, + .{ .font = AppFont.getStyle(10.5, .card_mono), .face = "Consolas", .height = -11, .weight = c.FW_NORMAL }, + .{ .font = AppFont.get(11, false), .face = "Segoe UI", .height = -11, .weight = c.FW_NORMAL }, + }; + var rgb_hashes: [3][32]u8 = undefined; + var first_proof: PaintRenderProof = undefined; + for (0..3) |render_index| { + const file_name = switch (render_index) { + 0 => "project-paint-v1.bmp", + 1 => "project-paint-v1-repeat-2.bmp", + else => "project-paint-v1-repeat-3.bmp", + }; + const artifact_path = try std.fs.path.join(allocator, &.{ transport, file_name }); + defer allocator.free(artifact_path); + const proof = try renderPaintArtifact(allocator, &fixture, artifact_path); + if (render_index == 0) { + first_proof = proof; + const dib = try ParityDib.init(1280, 720); + defer dib.deinit(); + for (font_requests) |request| { + font_facts[font_facts_len] = try inspectPaintFont( + allocator, + dib.dc, + request.font, + request.face, + request.height, + request.weight, + ); + font_facts_len += 1; + } + } else if (proof.dpi_x != first_proof.dpi_x or proof.dpi_y != first_proof.dpi_y) { + return error.PaintExportDeviceMetricsChanged; + } + rgb_hashes[render_index] = proof.rgb_sha256; + } + try std.testing.expectEqualSlices(u8, &rgb_hashes[0], &rgb_hashes[1]); + try std.testing.expectEqualSlices(u8, &rgb_hashes[0], &rgb_hashes[2]); + const source_commit = try std.process.getEnvVarOwned(allocator, "GRAPHCODE_PAINT_SOURCE_COMMIT"); + defer allocator.free(source_commit); + if (source_commit.len != 40) return error.PaintExportSourceCommitInvalid; + for (source_commit) |character| { + if (!std.ascii.isHex(character)) return error.PaintExportSourceCommitInvalid; + } + const receipt_path = try std.fs.path.join(allocator, &.{ transport, "paint-receipt.json" }); + defer allocator.free(receipt_path); + try writePaintReceipt(allocator, receipt_path, &fixture, first_proof, &font_facts, &rgb_hashes, source_commit); + + const artifact_path = try std.fs.path.join(allocator, &.{ transport, "project-paint-v1.bmp" }); + defer allocator.free(artifact_path); + const canonical_exists = blk: { + var file = std.fs.cwd().openFile(artifact_path, .{}) catch |err| switch (err) { + error.FileNotFound => break :blk false, + else => return err, + }; + file.close(); + break :blk true; + }; + std.debug.print("paint export artifact contract expected=1 actual={d}; renders=3 matching-rgb-hashes=1\n", .{@intFromBool(canonical_exists)}); + try std.testing.expectEqual(@as(usize, 1), @as(usize, @intFromBool(canonical_exists))); +} + test "loop card parity both production renderers paint named pill and mono meta regions" { const allocator = std.testing.allocator; const dib = try ParityDib.init(2200, 500);