Files
rocky 03bfbbe08b 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.
2026-10-20 20:06:36 +02:00

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)
}
}