| 1 | import XCTest |
| 2 | import CoreGraphics |
| 3 | @testable import LChatCore |
| 4 | |
| 5 | // 🤖 Pins the CGImage adaptation of the reference BlurHash (woltapp/blurhash's |
| 6 | // Swift files are UIImage-only and copy-in by design — no package): structure, |
| 7 | // round-trip colour fidelity, and malformed-hash rejection. A DCT port's bugs |
| 8 | // are subtle; these tests are the confidence the adaptation didn't drift. |
| 9 | final class BlurhashTests: XCTestCase { |
| 10 | private func solid(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, |
| 11 | w: Int = 32, h: Int = 24) -> CGImage { |
| 12 | let cs = CGColorSpaceCreateDeviceRGB() |
| 13 | let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, |
| 14 | bytesPerRow: w * 4, space: cs, |
| 15 | bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| 16 | // Components in the context's OWN space — the CGColor(red:green:blue:) |
| 17 | // convenience creates a generic-space colour that CG converts on draw |
| 18 | // (0.3 arrived as 0.405), which read as a codec bug. |
| 19 | ctx.setFillColor(CGColor(colorSpace: cs, components: [r, g, b, 1])!) |
| 20 | ctx.fill(CGRect(x: 0, y: 0, width: w, height: h)) |
| 21 | return ctx.makeImage()! |
| 22 | } |
| 23 | |
| 24 | private func averageRGB(_ image: CGImage) -> (Double, Double, Double) { |
| 25 | let w = image.width, h = image.height |
| 26 | var data = [UInt8](repeating: 0, count: w * h * 4) |
| 27 | let cs = CGColorSpaceCreateDeviceRGB() |
| 28 | let ctx = CGContext(data: &data, width: w, height: h, bitsPerComponent: 8, |
| 29 | bytesPerRow: w * 4, space: cs, |
| 30 | bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| 31 | ctx.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h)) |
| 32 | var r = 0.0, g = 0.0, b = 0.0 |
| 33 | for i in stride(from: 0, to: data.count, by: 4) { |
| 34 | r += Double(data[i]); g += Double(data[i + 1]); b += Double(data[i + 2]) |
| 35 | } |
| 36 | let n = Double(w * h) |
| 37 | return (r / n, g / n, b / n) |
| 38 | } |
| 39 | |
| 40 | func testHashShape() throws { |
| 41 | let hash = try XCTUnwrap(Blurhash.encode(solid(0.8, 0.3, 0.2))) |
| 42 | // 4Ă—3 components: 1 (size) + 1 (max AC) + 4 (DC) + 2Ă—11 (AC) = 28 chars. |
| 43 | XCTAssertEqual(hash.count, 28) |
| 44 | } |
| 45 | |
| 46 | // The shared vector grid: 8×6 RGBA, red ramps left→right, green top→bottom, |
| 47 | // blue fixed at 64 — keep IDENTICAL to vectorGrid() in test/blurhash.test.mjs. |
| 48 | private func vectorImage() -> CGImage { |
| 49 | let w = 8, h = 6 |
| 50 | var px = [UInt8](repeating: 0, count: w * h * 4) |
| 51 | for y in 0..<h { |
| 52 | for x in 0..<w { |
| 53 | let o = (y * w + x) * 4 |
| 54 | px[o] = UInt8((x * 255 + (w - 1) / 2) / (w - 1)) |
| 55 | px[o + 1] = UInt8((y * 255 + (h - 1) / 2) / (h - 1)) |
| 56 | px[o + 2] = 64 |
| 57 | px[o + 3] = 255 |
| 58 | } |
| 59 | } |
| 60 | let cs = CGColorSpaceCreateDeviceRGB() |
| 61 | let provider = CGDataProvider(data: Data(px) as CFData)! |
| 62 | return CGImage(width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 32, |
| 63 | bytesPerRow: w * 4, space: cs, |
| 64 | bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue), |
| 65 | provider: provider, decode: nil, shouldInterpolate: false, |
| 66 | intent: .defaultIntent)! |
| 67 | } |
| 68 | |
| 69 | func testCrossImplementationVector() throws { |
| 70 | // The one true hash for the vector grid — test/blurhash.test.mjs pins |
| 71 | // the same string. A change here without the twin is a platform drift. |
| 72 | XCTAssertEqual(try XCTUnwrap(Blurhash.encode(vectorImage())), |
| 73 | "LyI5d=39a|%0z2NKfQnSeXf7fQf7") |
| 74 | } |
| 75 | |
| 76 | func testRoundTripPreservesColour() throws { |
| 77 | for (r, g, b) in [(1.0, 0.2, 0.2), (0.1, 0.7, 0.3), (0.2, 0.3, 0.9)] { |
| 78 | let hash = try XCTUnwrap(Blurhash.encode(solid(r, g, b))) |
| 79 | let decoded = try XCTUnwrap(Blurhash.decode(hash)) |
| 80 | let avg = averageRGB(decoded) |
| 81 | XCTAssertEqual(avg.0 / 255, r, accuracy: 0.06, "red channel for \(hash)") |
| 82 | XCTAssertEqual(avg.1 / 255, g, accuracy: 0.06, "green channel for \(hash)") |
| 83 | XCTAssertEqual(avg.2 / 255, b, accuracy: 0.06, "blue channel for \(hash)") |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func testGradientRoundTripKeepsOrientation() throws { |
| 88 | // Left-red, right-blue: the decode must keep the gradient's direction. |
| 89 | let w = 32, h = 24 |
| 90 | let cs = CGColorSpaceCreateDeviceRGB() |
| 91 | let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, |
| 92 | bytesPerRow: w * 4, space: cs, |
| 93 | bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| 94 | ctx.setFillColor(CGColor(colorSpace: cs, components: [1, 0, 0, 1])!) |
| 95 | ctx.fill(CGRect(x: 0, y: 0, width: w / 2, height: h)) |
| 96 | ctx.setFillColor(CGColor(colorSpace: cs, components: [0, 0, 1, 1])!) |
| 97 | ctx.fill(CGRect(x: w / 2, y: 0, width: w / 2, height: h)) |
| 98 | let hash = try XCTUnwrap(Blurhash.encode(ctx.makeImage()!)) |
| 99 | let decoded = try XCTUnwrap(Blurhash.decode(hash, width: 32, height: 24)) |
| 100 | var left = [UInt8](repeating: 0, count: 4), right = left |
| 101 | let dctx = { (px: Int, out: inout [UInt8]) in |
| 102 | let c = CGContext(data: &out, width: 1, height: 1, bitsPerComponent: 8, |
| 103 | bytesPerRow: 4, space: cs, |
| 104 | bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)! |
| 105 | c.draw(decoded, in: CGRect(x: -px, y: 0, width: 32, height: 24)) |
| 106 | } |
| 107 | dctx(2, &left) |
| 108 | dctx(29, &right) |
| 109 | XCTAssertGreaterThan(left[0], left[2], "left edge reads red") |
| 110 | XCTAssertGreaterThan(right[2], right[0], "right edge reads blue") |
| 111 | } |
| 112 | |
| 113 | func testMalformedHashesRejected() { |
| 114 | XCTAssertNil(Blurhash.decode("")) |
| 115 | XCTAssertNil(Blurhash.decode("LEHV6")) // truncated |
| 116 | XCTAssertNil(Blurhash.decode("LEHV6nWB2yk8pyo0adR*.7kCMdnj!!")) // wrong length |
| 117 | XCTAssertNil(Blurhash.decode(String(repeating: "\u{1F600}", count: 28))) // non-base83 |
| 118 | } |
| 119 | } |
| 120 | |