feat(account): add QR invitation poster and 8-character invitation codes
- 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.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user