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:
@@ -5,6 +5,7 @@
|
||||
|
||||
import AuthenticationServices
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardHostSupport
|
||||
import XCTest
|
||||
|
||||
final class AccountCenterViewModelTests: XCTestCase {
|
||||
@@ -34,6 +35,185 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testPastedInputAcceptsCodeLinkAndChatMessage() {
|
||||
let inputs = [
|
||||
validCode,
|
||||
" \(validCode)\n",
|
||||
"https://osglab.com/i/\(validCode)",
|
||||
"https://osglab.com/i/\(validCode)?source=poster",
|
||||
"快来试试 OSGKeyboard:https://osglab.com/i/\(validCode)",
|
||||
"邀请你加入https://osglab.com/i/\(validCode),一起领积分",
|
||||
"Join me: <https://osglab.com/i/\(validCode)>"
|
||||
]
|
||||
|
||||
for input in inputs {
|
||||
XCTAssertEqual(
|
||||
ReferralUniversalLink.code(fromUserInput: input),
|
||||
validCode,
|
||||
"Failed to extract from \(input)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testPastedInputRejectsForeignLinksAndPlainText() {
|
||||
let inputs = [
|
||||
"",
|
||||
"hello",
|
||||
"https://example.com/i/\(validCode)",
|
||||
"http://osglab.com/i/\(validCode)",
|
||||
"https://osglab.com/i/short",
|
||||
String(validCode.dropLast())
|
||||
]
|
||||
|
||||
for input in inputs {
|
||||
XCTAssertNil(
|
||||
ReferralUniversalLink.code(fromUserInput: input),
|
||||
"Unexpectedly accepted \(input)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testTypedInputAcceptsShortCodeInAnyCaseWithOrWithoutHyphen() {
|
||||
let inputs = [
|
||||
"K7M2Q9XA",
|
||||
"k7m2-q9xa",
|
||||
" K7M2-Q9XA \n",
|
||||
"邀请码K7M2-Q9XA",
|
||||
"我的邀请码:K7M2-Q9XA,快来"
|
||||
]
|
||||
|
||||
for input in inputs {
|
||||
XCTAssertEqual(
|
||||
ReferralUniversalLink.code(fromUserInput: input),
|
||||
"K7M2Q9XA",
|
||||
"Failed to extract from \(input)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testTypedInputRejectsAmbiguousOrMalformedShortCodes() {
|
||||
let inputs = [
|
||||
"K7M2Q9X",
|
||||
"K7M2Q9XAB",
|
||||
"K7M-2Q9XA",
|
||||
"K0M2Q9XA",
|
||||
"KOM2Q9XA",
|
||||
"K1M2Q9XA",
|
||||
"KLM2Q9XA"
|
||||
]
|
||||
|
||||
for input in inputs {
|
||||
XCTAssertNil(
|
||||
ReferralUniversalLink.code(fromUserInput: input),
|
||||
"Unexpectedly accepted \(input)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testInvitationLinkWinsOverShortCodeLikeWordsInTheSameMessage() {
|
||||
// "HEXAGRAM" uses only short-code characters.
|
||||
let input = "HEXAGRAM https://osglab.com/i/\(validCode)"
|
||||
|
||||
XCTAssertEqual(ReferralUniversalLink.code(fromUserInput: input), validCode)
|
||||
}
|
||||
|
||||
func testShortCodeDisplayAddsHyphen() {
|
||||
XCTAssertEqual(ReferralShortCode.display("K7M2Q9XA"), "K7M2-Q9XA")
|
||||
XCTAssertEqual(ReferralShortCode.display("bogus"), "bogus")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPendingStoreKeepsShortCodesForRedemptionAfterSignIn() throws {
|
||||
let suiteName = "PendingReferralShortCode.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = UserDefaultsPendingReferralCodeStore(defaults: defaults)
|
||||
|
||||
store.save("K7M2Q9XA")
|
||||
|
||||
XCTAssertEqual(store.code, "K7M2Q9XA")
|
||||
}
|
||||
|
||||
func testBindingWindowFollowsServerDeadlineWhenPresent() throws {
|
||||
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
|
||||
let deadline = Date(timeIntervalSince1970: 1_700_003_600)
|
||||
let profile = ReferralProfile(
|
||||
code: try makeReferralCode(),
|
||||
binding: nil,
|
||||
bindingDeadline: deadline
|
||||
)
|
||||
|
||||
XCTAssertTrue(ReferralBindingWindow.isOpen(for: account, profile: profile, now: deadline))
|
||||
XCTAssertFalse(ReferralBindingWindow.isOpen(
|
||||
for: account,
|
||||
profile: profile,
|
||||
now: deadline.addingTimeInterval(1)
|
||||
))
|
||||
}
|
||||
|
||||
func testBindingWindowFallsBackToSevenDaysWithoutServerDeadline() throws {
|
||||
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
|
||||
let profile = ReferralProfile(code: try makeReferralCode(), binding: nil)
|
||||
let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
XCTAssertTrue(ReferralBindingWindow.isOpen(
|
||||
for: account,
|
||||
profile: profile,
|
||||
now: start.addingTimeInterval(6 * 24 * 60 * 60)
|
||||
))
|
||||
}
|
||||
|
||||
func testBindingWindowClosesOnceBound() throws {
|
||||
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
|
||||
let binding = try JSONDecoder().decode(
|
||||
ReferralBinding.self,
|
||||
from: Data(#"{"inviterUserId":"x"}"#.utf8)
|
||||
)
|
||||
let profile = ReferralProfile(
|
||||
code: try makeReferralCode(),
|
||||
binding: binding,
|
||||
bindingDeadline: .distantFuture
|
||||
)
|
||||
|
||||
XCTAssertFalse(ReferralBindingWindow.isOpen(
|
||||
for: account,
|
||||
profile: profile,
|
||||
now: Date(timeIntervalSince1970: 1_700_000_001)
|
||||
))
|
||||
}
|
||||
|
||||
private func makeReferralCode() throws -> ReferralCode {
|
||||
ReferralCode(
|
||||
code: validCode,
|
||||
inviteURL: try XCTUnwrap(URL(string: "https://osglab.com/i/\(validCode)")),
|
||||
campaignID: nil,
|
||||
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
shortCode: "K7M2Q9XA"
|
||||
)
|
||||
}
|
||||
|
||||
func testBindingWindowClosesSevenDaysAfterRegistration() {
|
||||
let registeredAt: Int64 = 1_700_000_000
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: registeredAt
|
||||
)
|
||||
let start = Date(timeIntervalSince1970: TimeInterval(registeredAt))
|
||||
|
||||
XCTAssertTrue(ReferralBindingWindow.isOpen(
|
||||
for: account,
|
||||
now: start.addingTimeInterval(6 * 24 * 60 * 60)
|
||||
))
|
||||
XCTAssertFalse(ReferralBindingWindow.isOpen(
|
||||
for: account,
|
||||
now: start.addingTimeInterval(7 * 24 * 60 * 60)
|
||||
))
|
||||
XCTAssertFalse(ReferralBindingWindow.isOpen(
|
||||
for: AccountSession(accountID: UUID(), createdAtEpochSeconds: 0),
|
||||
now: start
|
||||
))
|
||||
}
|
||||
|
||||
func testReferralSummaryCountsEveryServerStatus() {
|
||||
let referrals = [
|
||||
makeReferral(status: .pending),
|
||||
@@ -439,6 +619,151 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(coordinator.operationErrorKey, "account.error.redeemReferral")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPermanentRedemptionRejectionsDropTheStoredCode() async {
|
||||
let cases: [(AccountAPIError, String)] = [
|
||||
(
|
||||
.server(statusCode: 404, code: "http_error", message: "Referral code does not exist"),
|
||||
"account.referral.redeem.error.invalid"
|
||||
),
|
||||
(
|
||||
.server(statusCode: 422, code: "http_error", message: "Referral binding window has expired"),
|
||||
"account.referral.redeem.error.expired"
|
||||
),
|
||||
(
|
||||
.server(statusCode: 409, code: "http_error", message: "Self-referral is not allowed"),
|
||||
"account.referral.redeem.error.self"
|
||||
),
|
||||
(
|
||||
.server(
|
||||
statusCode: 409,
|
||||
code: "http_error",
|
||||
message: "This account is already bound to another inviter"
|
||||
),
|
||||
"account.referral.redeem.error.alreadyBound"
|
||||
),
|
||||
(
|
||||
.conflict("This account is not eligible for referral rewards"),
|
||||
"account.referral.redeem.error.ineligible"
|
||||
)
|
||||
]
|
||||
|
||||
for (error, expectedKey) in cases {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
redemptionError: error
|
||||
)
|
||||
let store = InMemoryPendingReferralStore(code: validCode)
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: store
|
||||
)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
XCTAssertNil(store.code, "Kept code after \(error)")
|
||||
XCTAssertNil(coordinator.pendingReferralCode)
|
||||
XCTAssertEqual(coordinator.operationErrorKey, expectedKey)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPastedInvitationWhileSignedOutIsSavedForSignIn() async {
|
||||
let service = AccountServiceSpy(restoredSession: nil)
|
||||
let store = InMemoryPendingReferralStore()
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: store
|
||||
)
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
let invalid = await coordinator.submitReferralInput("not an invitation")
|
||||
let saved = await coordinator.submitReferralInput(
|
||||
"Join me https://osglab.com/i/\(validCode)"
|
||||
)
|
||||
|
||||
XCTAssertEqual(invalid, .invalidInput)
|
||||
XCTAssertEqual(saved, .savedForSignIn)
|
||||
XCTAssertEqual(store.code, validCode)
|
||||
XCTAssertEqual(coordinator.pendingReferralCode, validCode)
|
||||
XCTAssertFalse(coordinator.consumeAccountCenterPresentation())
|
||||
let redeemedCodes = await service.redeemedCodes()
|
||||
XCTAssertEqual(redeemedCodes, [])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPastedInvitationWhileSignedInRedeemsImmediately() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: account,
|
||||
snapshot: makeSnapshot(account: account)
|
||||
)
|
||||
let store = InMemoryPendingReferralStore()
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: store
|
||||
)
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
let outcome = await coordinator.submitReferralInput(validCode)
|
||||
|
||||
XCTAssertEqual(outcome, .redeemed)
|
||||
XCTAssertNil(store.code)
|
||||
XCTAssertNil(coordinator.pendingReferralCode)
|
||||
XCTAssertNil(coordinator.operationErrorKey)
|
||||
let redeemedCodes = await service.redeemedCodes()
|
||||
XCTAssertEqual(redeemedCodes, [validCode])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testPastedInvitationRejectionIsReturnedInsteadOfAlerting() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
redemptionError: .server(
|
||||
statusCode: 422,
|
||||
code: "http_error",
|
||||
message: "Referral binding window has expired"
|
||||
)
|
||||
)
|
||||
let store = InMemoryPendingReferralStore()
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: store
|
||||
)
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
let outcome = await coordinator.submitReferralInput(validCode)
|
||||
|
||||
XCTAssertEqual(outcome, .failed(messageKey: "account.referral.redeem.error.expired"))
|
||||
XCTAssertNil(store.code)
|
||||
XCTAssertNil(coordinator.operationErrorKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSignOutClearsRemoteAccountStateOnly() async {
|
||||
let account = AccountSession(
|
||||
@@ -800,6 +1125,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
private let centerSnapshot: AccountCenterSnapshot?
|
||||
private let refreshedSnapshot: AccountCenterSnapshot?
|
||||
private let shouldFailRedemption: Bool
|
||||
private let redemptionError: AccountAPIError?
|
||||
private let shouldFailAccountRefresh: Bool
|
||||
private let shouldFailSignOut: Bool
|
||||
private let signOutDelayNanoseconds: UInt64
|
||||
@@ -822,6 +1148,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
snapshot: AccountCenterSnapshot? = nil,
|
||||
refreshedSnapshot: AccountCenterSnapshot? = nil,
|
||||
shouldFailRedemption: Bool = false,
|
||||
redemptionError: AccountAPIError? = nil,
|
||||
shouldFailAccountRefresh: Bool = false,
|
||||
shouldFailSignOut: Bool = false,
|
||||
signOutDelayNanoseconds: UInt64 = 0,
|
||||
@@ -836,6 +1163,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
centerSnapshot = snapshot
|
||||
self.refreshedSnapshot = refreshedSnapshot
|
||||
self.shouldFailRedemption = shouldFailRedemption
|
||||
self.redemptionError = redemptionError
|
||||
self.shouldFailAccountRefresh = shouldFailAccountRefresh
|
||||
self.shouldFailSignOut = shouldFailSignOut
|
||||
self.signOutDelayNanoseconds = signOutDelayNanoseconds
|
||||
@@ -925,6 +1253,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
if shouldFailRedemption {
|
||||
throw AccountServiceSpyError.failed
|
||||
}
|
||||
if let redemptionError {
|
||||
throw redemptionError
|
||||
}
|
||||
redeemed.append(code)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,59 @@ final class ReferralProfileTests: XCTestCase {
|
||||
XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL)
|
||||
XCTAssertNil(profile.code.campaignID)
|
||||
XCTAssertNotNil(profile.binding)
|
||||
XCTAssertNil(profile.code.shortCode)
|
||||
XCTAssertNil(profile.bindingDeadline)
|
||||
}
|
||||
|
||||
func testReferralProfileDecodesShortCodeAndBindingDeadlineAndSurvivesCache() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
{
|
||||
"code": {
|
||||
"code": "Abcdefghij_1234567890-",
|
||||
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
|
||||
"createdAt": "2026-08-20T12:34:56Z",
|
||||
"shortCode": "K7M2Q9XA"
|
||||
},
|
||||
"bindingDeadline": "2026-08-27T12:34:56Z"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
|
||||
let cached = try JSONDecoder().decode(
|
||||
ReferralProfile.self,
|
||||
from: JSONEncoder().encode(profile)
|
||||
)
|
||||
|
||||
XCTAssertEqual(profile.code.shortCode, "K7M2Q9XA")
|
||||
XCTAssertEqual(profile.code.displayShortCode, "K7M2-Q9XA")
|
||||
XCTAssertEqual(
|
||||
profile.bindingDeadline,
|
||||
ISO8601DateFormatter().date(from: "2026-08-27T12:34:56Z")
|
||||
)
|
||||
XCTAssertEqual(cached, profile)
|
||||
}
|
||||
|
||||
func testMalformedShortCodeIsDroppedWithoutRejectingTheProfile() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
{
|
||||
"code": {
|
||||
"code": "Abcdefghij_1234567890-",
|
||||
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
|
||||
"createdAt": "2026-08-20T12:34:56Z",
|
||||
"shortCode": "0OIL0OIL"
|
||||
},
|
||||
"bindingDeadline": "not a date"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
|
||||
|
||||
XCTAssertNil(profile.code.shortCode)
|
||||
XCTAssertNil(profile.bindingDeadline)
|
||||
}
|
||||
|
||||
func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// ScreenBrightnessBoostTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Whatever order holds and scene changes arrive in, the user's own
|
||||
// brightness must come back once nothing needs the boost.
|
||||
|
||||
@testable import OSGKeyboard
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class ScreenBrightnessBoostTests: XCTestCase {
|
||||
private final class FakeScreen: ScreenBrightnessControlling {
|
||||
var brightness: CGFloat
|
||||
init(brightness: CGFloat) { self.brightness = brightness }
|
||||
}
|
||||
|
||||
func testHoldRaisesBrightnessAndReleaseRestoresIt() {
|
||||
let screen = FakeScreen(brightness: 0.4)
|
||||
let boost = ScreenBrightnessBoost { screen }
|
||||
|
||||
boost.acquire()
|
||||
XCTAssertEqual(screen.brightness, 1)
|
||||
|
||||
boost.release()
|
||||
XCTAssertEqual(screen.brightness, 0.4)
|
||||
}
|
||||
|
||||
func testOverlappingHoldsKeepTheOriginalLevel() {
|
||||
let screen = FakeScreen(brightness: 0.3)
|
||||
let boost = ScreenBrightnessBoost { screen }
|
||||
|
||||
// A new view appears before the previous one disappears.
|
||||
boost.acquire()
|
||||
boost.acquire()
|
||||
boost.release()
|
||||
XCTAssertEqual(screen.brightness, 1)
|
||||
|
||||
boost.release()
|
||||
XCTAssertEqual(screen.brightness, 0.3)
|
||||
}
|
||||
|
||||
func testBackgroundRestoresAndForegroundReapplies() {
|
||||
let screen = FakeScreen(brightness: 0.5)
|
||||
let boost = ScreenBrightnessBoost { screen }
|
||||
boost.acquire()
|
||||
|
||||
boost.setInForeground(false)
|
||||
XCTAssertEqual(screen.brightness, 0.5)
|
||||
|
||||
boost.setInForeground(true)
|
||||
XCTAssertEqual(screen.brightness, 1)
|
||||
|
||||
boost.release()
|
||||
XCTAssertEqual(screen.brightness, 0.5)
|
||||
}
|
||||
|
||||
func testUnbalancedReleaseLeavesBrightnessAlone() {
|
||||
let screen = FakeScreen(brightness: 0.6)
|
||||
let boost = ScreenBrightnessBoost { screen }
|
||||
|
||||
boost.release()
|
||||
boost.acquire()
|
||||
XCTAssertEqual(screen.brightness, 1)
|
||||
|
||||
boost.release()
|
||||
XCTAssertEqual(screen.brightness, 0.6)
|
||||
}
|
||||
|
||||
func testMissingScreenIsRetriedOnTheNextChange() {
|
||||
var screen: FakeScreen?
|
||||
let boost = ScreenBrightnessBoost { screen }
|
||||
|
||||
boost.acquire()
|
||||
screen = FakeScreen(brightness: 0.2)
|
||||
boost.setInForeground(true)
|
||||
XCTAssertEqual(screen?.brightness, 1)
|
||||
|
||||
boost.release()
|
||||
XCTAssertEqual(screen?.brightness, 0.2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user