96 lines
5.6 KiB
Python
96 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Render native icons into memory and inspect pixels; no app, windows or screenshots."""
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
with tempfile.TemporaryDirectory(prefix="lemana-icons-") as temporary:
|
|
folder = Path(temporary)
|
|
source = folder / "IconChecks.swift"
|
|
source.write_text(r'''
|
|
import AppKit
|
|
|
|
func pixels(size: Int = 96, _ draw: (CGContext) -> Void) -> [UInt8] {
|
|
let context = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: size * 4,
|
|
space: CGColorSpace(name: CGColorSpace.sRGB)!,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)!
|
|
context.translateBy(x: 0, y: CGFloat(size))
|
|
context.scaleBy(x: CGFloat(size) / 24, y: -CGFloat(size) / 24)
|
|
draw(context)
|
|
return Array(UnsafeBufferPointer(start: context.data!.assumingMemoryBound(to: UInt8.self), count: size * size * 4))
|
|
}
|
|
|
|
func session(_ style: VPNIndicatorStyle, _ progress: Double?, dark: Bool, urgency: SessionTiming.Urgency = .normal) -> [UInt8] {
|
|
pixels { VPNIcons.drawSession(style: style, progress: progress, urgency: urgency, in: $0, dark: dark) }
|
|
}
|
|
|
|
func pixel(_ bytes: [UInt8], _ x: Int, _ y: Int) -> [UInt8] {
|
|
let index = (y * 4 * 96 + x * 4) * 4
|
|
return Array(bytes[index..<index + 4])
|
|
}
|
|
|
|
@main struct IconChecks {
|
|
static func main() {
|
|
precondition(VPNIndicatorStyle(rawValue: "ring") == .ring)
|
|
precondition(VPNIndicatorStyle(rawValue: "clock") == .clock)
|
|
precondition(VPNIndicatorStyle(rawValue: "time") == .time)
|
|
for dark in [false, true] {
|
|
let image = VPNIcons.sessionImage(style: .shield, progress: 0.5, urgency: .normal, dark: dark)
|
|
precondition(image.size == NSSize(width: 16, height: 16))
|
|
let native = image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
|
|
let actual = pixels { context in
|
|
context.translateBy(x: 0, y: 24)
|
|
context.scaleBy(x: 1, y: -1)
|
|
context.draw(native, in: CGRect(x: 0, y: 0, width: 24, height: 24))
|
|
}
|
|
precondition(pixel(actual, 12, 8)[3] < pixel(actual, 12, 18)[3], "native NSImage must fill from bottom")
|
|
var seen = Set<[UInt8]>()
|
|
for icon in VPNIcon.allCases {
|
|
let bytes = pixels { VPNIcons.draw(icon, in: $0, dark: dark) }
|
|
precondition(bytes.contains(where: { $0 != 0 }), "empty icon: \(icon)")
|
|
precondition(seen.insert(bytes).inserted, "duplicate icon: \(icon)")
|
|
for size in [16, 32] {
|
|
let compact = pixels(size: size) { VPNIcons.draw(icon, in: $0, dark: dark) }
|
|
let count = stride(from: 3, to: compact.count, by: 4).filter { compact[$0] > 30 }.count
|
|
precondition(count > 10, "invisible icon at \(size): \(icon)")
|
|
}
|
|
}
|
|
for style in [VPNIndicatorStyle.shield, .ring] {
|
|
let full = session(style, 0, dark: dark)
|
|
let quarter = session(style, 0.75, dark: dark)
|
|
let half = session(style, 0.5, dark: dark)
|
|
let spent = session(style, 1, dark: dark)
|
|
precondition(pixel(full, 12, 8)[3] > 200, "session must start brightly filled")
|
|
precondition((75...80).contains(pixel(spent, 12, 18)[3]), "spent interior must stay visible")
|
|
precondition(pixel(half, 7, 10) == pixel(spent, 7, 10), "spent part must dim from top")
|
|
precondition(pixel(half, 12, 18) == pixel(full, 12, 18), "remaining part must stay bright")
|
|
precondition(pixel(quarter, 12, 18) == pixel(half, 12, 18))
|
|
precondition(pixel(quarter, 7, 14) == pixel(spent, 7, 14))
|
|
precondition(pixel(full, 12, 18) == pixel(half, 12, 18))
|
|
precondition(pixel(full, 1, 1)[3] == 0, "fill escaped its shape")
|
|
precondition(session(style, -1, dark: dark) == full)
|
|
precondition(session(style, 2, dark: dark) == spent)
|
|
precondition(session(style, nil, dark: dark) == pixels { VPNIcons.draw(.noTime, in: $0, dark: dark) })
|
|
precondition(session(style, .nan, dark: dark) == session(style, nil, dark: dark))
|
|
let warning = pixel(session(style, 0.5, dark: dark, urgency: .warning), 12, 18)
|
|
let critical = pixel(session(style, 0.5, dark: dark, urgency: .critical), 12, 18)
|
|
precondition(warning != pixel(half, 12, 18) && warning != critical, "urgency colors must differ")
|
|
// Expiry stays legible over the dimmed interior in either theme.
|
|
let expired = session(style, 1, dark: dark, urgency: .critical)
|
|
precondition(pixel(expired, 12, 8)[3] > pixel(expired, 12, 18)[3])
|
|
}
|
|
precondition(session(.clock, 0.25, dark: dark) != session(.clock, 0.75, dark: dark))
|
|
precondition(session(.time, 0.5, dark: dark) != session(.time, 1, dark: dark))
|
|
}
|
|
print("PASS native icons: both themes, distinct states/actions, 16/32 px visibility, bright remaining fill, dim spent area, clipping, urgency, expiry and unknown time")
|
|
}
|
|
}
|
|
''')
|
|
binary = folder / "icon-check"
|
|
subprocess.run(["swiftc", "-parse-as-library", "-module-cache-path", str(ROOT / "app/.build/log-test-module-cache"),
|
|
str(ROOT / "app/Sources/LemanaVPN/SessionTiming.swift"),
|
|
str(ROOT / "app/Sources/LemanaVPN/VPNIcons.swift"), str(source), "-o", str(binary)], check=True)
|
|
subprocess.run([str(binary)], check=True)
|