From 7cbd44dd1cfe97126e7fc996214e458525d2980c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 16 Sep 2026 13:20:29 -0300 Subject: Align macOS rendering with Linux and establish parity regressions --- test/macos_e2e.swift | 185 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 6 deletions(-) (limited to 'test/macos_e2e.swift') diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift index 43627a7e..5427c3ad 100644 --- a/test/macos_e2e.swift +++ b/test/macos_e2e.swift @@ -92,6 +92,8 @@ private final class Driver: PardesViewDelegate { private var window: NSWindow? private var view: PardesView? private var booted = false + private var nativeMetrics = false + private let scriptDirectory: String private var observer: NSObjectProtocol? private var output = "" private var nextAnimationFrame: Date? @@ -101,7 +103,8 @@ private final class Driver: PardesViewDelegate { /// The script's own file name, for error messages. private let scriptName: String - init(scriptName: String) { + init(scriptName: String, scriptDirectory: String) { + self.scriptDirectory = scriptDirectory self.scriptName = scriptName // The app answers this notification with pump(); so does the harness, // and for the same reason — the view calls into the core and never @@ -232,11 +235,17 @@ private final class Driver: PardesViewDelegate { let grid = view.gridSize // Points, not backing-store pixels. The app passes physical pixels // because the native PDF placement path measures in them; a golden must - // not change when the same script runs on a Retina display, and nothing - // here places a PDF. - let cellW = UInt16(max(1, min(view.cellWidth.rounded(), CGFloat(UInt16.max)))) - let cellH = UInt16(max(1, min(view.cellHeight.rounded(), CGFloat(UInt16.max)))) + // not change when the same grid script runs on a Retina display. Pixel + // parity scripts opt into native metrics below, including PDF placement. + let scale: CGFloat = nativeMetrics ? (window?.backingScaleFactor ?? 1) : 1 + let cellW = UInt16(max(1, min((view.cellWidth * scale).rounded(), CGFloat(UInt16.max)))) + let cellH = UInt16(max(1, min((view.cellHeight * scale).rounded(), CGFloat(UInt16.max)))) pardes_resize(grid.cols, grid.rows, cellW, cellH) + if nativeMetrics { + pardes_row_metrics(cellW, cellH, + UInt16(max(1, (view.taglineCellWidth * scale).rounded())), + UInt16(max(1, (view.taglineBandHeight * scale).rounded()))) + } pump() } @@ -498,6 +507,34 @@ private final class Driver: PardesViewDelegate { output += "== draw \(label) \(w)x\(h) nonblank\n" } + /// Forge's topbar must contain light ink, not CoreText's default black. + /// Restrict the sample to text, excluding grips, pane bodies and cursors. + private func drawTagInk(_ label: String) throws { + let view = try live() + let bounds = view.bounds + guard let rep = view.bitmapImageRepForCachingDisplay(in: bounds) else { + throw ScriptError(message: "draw-tag-ink: no bitmap") + } + view.cacheDisplay(in: bounds, to: rep) + let scale = CGFloat(rep.pixelsWide) / bounds.width + var brightest: CGFloat = 0 + for y in 0.. 0.5 else { + throw ScriptError(message: "draw-tag-ink: Forge topbar has no light foreground ink") + } + if let directory = ProcessInfo.processInfo.environment["PARDES_TEST_CAPTURE_DIR"], + let png = rep.representation(using: .png, properties: [:]) { + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent(label + ".png")) + } + output += "== tag-ink \(label) visible\n" + } + /// The alpha range of a drawn frame, recorded into the golden. /// /// The contract WindowOpacity states (shaders/ui.frag.glsl, and @@ -543,6 +580,122 @@ private final class Driver: PardesViewDelegate { output += "== draw-opacity \(label) \(w)x\(h) alpha \(lowest)..\(highest)\n" } + private func renderedCapture(_ label: String) throws -> NSBitmapImageRep { + let view = try live() + guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { + throw ScriptError(message: "capture: no bitmap") + } + view.cacheDisplay(in: view.bounds, to: rep) + if let directory = ProcessInfo.processInfo.environment["PARDES_TEST_CAPTURE_DIR"], + let png = rep.representation(using: .png, properties: [:]) { + try png.write(to: URL(fileURLWithPath: directory).appendingPathComponent(label + ".png")) + } + return rep + } + + // colorAt labels components as calibrated RGB even for a Display P3 + // bitmap. Reattach the bitmap's actual profile before comparing sRGB. + private func sample(_ rep: NSBitmapImageRep, x: Int, y: Int) -> NSColor? { + guard let raw = rep.colorAt(x: x, y: y) else { return nil } + let components = [raw.redComponent, raw.greenComponent, raw.blueComponent, raw.alphaComponent] + return NSColor(colorSpace: rep.colorSpace, components: components, count: 4).usingColorSpace(.sRGB) + } + + private func checkBackgrounds(_ label: String) throws { + let view = try live(), rep = try renderedCapture(label) + let scale = CGFloat(rep.pixelsWide) / view.bounds.width + func check(x: CGFloat, y: CGFloat, rgb: UInt32, name: String) throws { + guard let c = sample(rep, x: Int(x * scale), y: Int(y * scale)) else { + throw ScriptError(message: "\(name): no pixel") + } + guard abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 else { + throw ScriptError(message: "\(name): background alpha \(c.alphaComponent), expected \(view.backgroundAlpha)") + } + if view.backgroundAlpha > 0 { + let actual = [c.redComponent, c.greenComponent, c.blueComponent] + let expected = [CGFloat((rgb >> 16) & 255), CGFloat((rgb >> 8) & 255), CGFloat(rgb & 255)] + guard zip(actual, expected).allSatisfy({ abs($0 * 255 - $1) < 4 }) else { + throw ScriptError(message: "\(name): tag background does not cover the full row: \(actual) vs \(expected)") + } + } + } + for i in 0.. 0 else { continue } + let x = CGFloat(pardes_tag_layer_value(i, 4) + pardes_tag_layer_value(i, 6)) * view.cellWidth - 2 / scale + let top = CGFloat(pardes_tag_layer_value(i, 5)) * view.cellHeight + let height = Int(view.cellHeight * scale) + let kind = pardes_tag_layer_value(i, 1) + let bottom = kind == 2 && pardes_tag_layer_value(i, 11) != 0 + for pixel in 0.. 0 else { continue } + headers += count + let x = CGFloat(pardes_body_layer_value(i, 3) + pardes_body_layer_value(i, 5)) * view.cellWidth - 2 / scale + let top = CGFloat(pardes_body_layer_value(i, 4)) * view.cellHeight + for row in 0..> 16) & 255)) < 4, + abs(c.greenComponent * 255 - CGFloat((rgb >> 8) & 255)) < 4, + abs(c.blueComponent * 255 - CGFloat(rgb & 255)) < 4, + abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 else { + throw ScriptError(message: "context: separator is misplaced or has accumulated opacity") + } + } + } + guard headers >= 2, separators > 0 else { + throw ScriptError(message: "context: expected nested compact declarations, got \(headers) rows") + } + output += "== context \(label) compact separators verified\n" + } + + private func checkPDF(_ label: String) throws { + let view = try live(), rep = try renderedCapture(label) + guard nativeMetrics, pardes_frame_images() > 0, let images = pardes_frame_image_list() else { + throw ScriptError(message: "PDF: no native image placements") + } + let scale = window?.backingScaleFactor ?? 1 + let image = images[0] + let x = CGFloat(image.cell_x) * view.cellWidth + CGFloat(image.dst_x) / scale + let y = CGFloat(image.cell_y) * view.cellHeight + (CGFloat(image.dst_y) + CGFloat(image.offset_y)) / scale + let width = CGFloat(image.dst_w) / scale, height = CGFloat(image.dst_h) / scale + guard width > view.cellWidth * 4, height > view.cellHeight * 2, + y >= CGFloat(image.cell_y) * view.cellHeight - 1, + x + width <= view.bounds.width + 1, y + height <= view.bounds.height + 1 else { + throw ScriptError(message: "PDF: placement is outside its physical-pixel viewport") + } + let backing = CGFloat(rep.pixelsWide) / view.bounds.width + var matched = 0 + for fraction in [CGFloat(0.2), 0.4, 0.6, 0.8] { + if let c = rep.colorAt(x: Int((x + width * fraction) * backing), + y: Int((y + height * fraction) * backing)), abs(c.alphaComponent - view.backgroundAlpha) < 2.0 / 255 { matched += 1 } + } + guard matched == 4 else { throw ScriptError(message: "PDF: raster opacity does not match WindowOpacity") } + output += "== pdf \(label) native page opacity verified\n" + } + // MARK: - the script func run(source: String) throws -> String { @@ -569,6 +722,23 @@ private final class Driver: PardesViewDelegate { let args = rest.split(separator: " ").map(String.init) switch cmd { + case "native-metrics": + nativeMetrics = true + pardesViewDidResize(try live()) + case "open-fixture": + _ = try live() + let path = URL(fileURLWithPath: scriptDirectory).appendingPathComponent(rest).standardized.path + let command = "Look " + path + pardes_command(command, command.utf8.count) + pump() + case "check-backgrounds": + try checkBackgrounds(rest) + case "check-context": + try checkContext(rest) + case "check-pdf": + try checkPDF(rest) + case "capture": + _ = try renderedCapture(rest) case "start": let rows = try int(args, 0, "start rows") let cols = try int(args, 1, "start cols") @@ -882,6 +1052,9 @@ private final class Driver: PardesViewDelegate { // place it can be checked: the core's cell buffer says nothing about // alpha, and `draw` deliberately compares channels without knowing // which one is which. + case "draw-tag-ink": + try drawTagInk(rest) + case "draw-opacity": guard !rest.isEmpty else { throw ScriptError(message: "draw-opacity needs a label") } try drawOpacity(rest) @@ -1125,7 +1298,7 @@ private func runOne(scriptPath: String, update: Bool) -> Bool { let goldenPath = String(scriptPath.dropLast(".snap".count)) + ".golden" let actualPath = String(scriptPath.dropLast(".snap".count)) + ".actual" - let driver = Driver(scriptName: name) + let driver = Driver(scriptName: name, scriptDirectory: (scriptPath as NSString).deletingLastPathComponent) // pardes_deinit is mandatory, not tidiness: the ABI is a singleton, so a // core left standing makes the NEXT script's pardes_init return 1. defer { driver.shutdown() } -- cgit v1.3