- Invite opens an appearance-matched poster with the app icon, inviter name, QR code, and short code; tap for a full-brightness QR view; save (add-only Photos access), share, or copy the link. Mac shows the poster in a popover and can save it as PNG. - Accounts get look-alike-free 8-character codes. "I have a code" accepts a code, link, or pasted message during the server-defined binding window. - Permanently rejected redemptions show a specific message and drop the stored code instead of retrying on every sign-in.
124 lines
4.4 KiB
Swift
124 lines
4.4 KiB
Swift
// InvitePosterTests.swift
|
|
// OSGKeyboardTests
|
|
//
|
|
// The poster is only useful if a camera can read it back, so these tests
|
|
// decode the generated QR code instead of only checking that an image exists.
|
|
|
|
import CoreImage
|
|
@testable import OSGKeyboard
|
|
import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
final class InvitePosterTests: XCTestCase {
|
|
private let inviteURL = URL(
|
|
string: "https://osglab.com/i/Abcdefghij_1234567890-?source=server"
|
|
)!
|
|
|
|
func testQRCodeDecodesToTheExactInvitationLink() throws {
|
|
let image = try XCTUnwrap(InviteQRCode.makeImage(for: inviteURL))
|
|
|
|
XCTAssertEqual(decodedMessages(in: image), [inviteURL.absoluteString])
|
|
}
|
|
|
|
@MainActor
|
|
func testPosterExportsAtFixedSizeAndStaysScannable() throws {
|
|
let poster = try XCTUnwrap(
|
|
InvitePosterRenderer.render(
|
|
inviteURL: inviteURL,
|
|
inviterName: nil,
|
|
language: .chinese,
|
|
colorScheme: .light
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(poster.width, 1080)
|
|
XCTAssertEqual(poster.height, 1440)
|
|
XCTAssertEqual(decodedMessages(in: poster), [inviteURL.absoluteString])
|
|
XCTAssertGreaterThan(cornerLuminance(of: poster), 0.95)
|
|
}
|
|
|
|
@MainActor
|
|
func testDarkPosterWithShortCodeAndLongNameStaysScannable() throws {
|
|
let poster = try XCTUnwrap(
|
|
InvitePosterRenderer.render(
|
|
inviteURL: inviteURL,
|
|
shortCode: "K7M2-Q9XA",
|
|
inviterName: "一位用户名比较长但仍然需要完整显示的朋友",
|
|
language: .chinese,
|
|
colorScheme: .dark
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(poster.width, 1080)
|
|
XCTAssertEqual(poster.height, 1440)
|
|
XCTAssertEqual(decodedMessages(in: poster), [inviteURL.absoluteString])
|
|
XCTAssertGreaterThan(
|
|
darkPixelFraction(
|
|
in: poster,
|
|
crop: CGRect(x: 300, y: 600, width: 480, height: 500)
|
|
),
|
|
0.15
|
|
)
|
|
// The graphite paper carries a subtle daylight wash, but remains
|
|
// distinctly dark rather than approaching the warm-paper surface.
|
|
XCTAssertLessThan(cornerLuminance(of: poster), 0.18)
|
|
}
|
|
|
|
private func decodedMessages(in image: CGImage) -> [String] {
|
|
let detector = CIDetector(
|
|
ofType: CIDetectorTypeQRCode,
|
|
context: nil,
|
|
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
|
|
)
|
|
return (detector?.features(in: CIImage(cgImage: image)) ?? [])
|
|
.compactMap { ($0 as? CIQRCodeFeature)?.messageString }
|
|
}
|
|
|
|
private func cornerLuminance(of image: CGImage) -> Double {
|
|
guard let corner = image.cropping(to: CGRect(x: 0, y: 0, width: 1, height: 1)) else {
|
|
return -1
|
|
}
|
|
var pixel = [UInt8](repeating: 0, count: 4)
|
|
let context = CGContext(
|
|
data: &pixel,
|
|
width: 1,
|
|
height: 1,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: 4,
|
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
)
|
|
context?.draw(corner, in: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
return (0.2126 * Double(pixel[0])
|
|
+ 0.7152 * Double(pixel[1])
|
|
+ 0.0722 * Double(pixel[2])) / 255
|
|
}
|
|
|
|
private func darkPixelFraction(in image: CGImage, crop: CGRect) -> Double {
|
|
guard let cropped = image.cropping(to: crop) else { return 0 }
|
|
let width = 120
|
|
let height = 120
|
|
var pixels = [UInt8](repeating: 0, count: width * height * 4)
|
|
guard let context = CGContext(
|
|
data: &pixels,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: width * 4,
|
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
) else {
|
|
return 0
|
|
}
|
|
context.interpolationQuality = .none
|
|
context.draw(cropped, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
let darkCount = (0..<(width * height)).count { index in
|
|
let offset = index * 4
|
|
return Int(pixels[offset])
|
|
+ Int(pixels[offset + 1])
|
|
+ Int(pixels[offset + 2]) < 3 * 100
|
|
}
|
|
return Double(darkCount) / Double(width * height)
|
|
}
|
|
}
|