Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// PublicContentRefreshServiceTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
private struct PublicContentStubResponse: Sendable {
|
||||
let statusCode: Int
|
||||
let data: Data
|
||||
let headers: [String: String]
|
||||
}
|
||||
|
||||
private actor QueuePublicContentTransport: PublicContentHTTPTransport {
|
||||
private var responses: [PublicContentStubResponse]
|
||||
private var requests: [URLRequest] = []
|
||||
private let delay: Duration?
|
||||
|
||||
init(responses: [PublicContentStubResponse], delay: Duration? = nil) {
|
||||
self.responses = responses
|
||||
self.delay = delay
|
||||
}
|
||||
|
||||
func append(_ response: PublicContentStubResponse) {
|
||||
responses.append(response)
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
requests.append(request)
|
||||
if let delay {
|
||||
try await Task.sleep(for: delay)
|
||||
}
|
||||
guard !responses.isEmpty else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
let stub = responses.removeFirst()
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: stub.statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: stub.headers
|
||||
)!
|
||||
return (stub.data, response)
|
||||
}
|
||||
|
||||
func recordedRequests() -> [URLRequest] {
|
||||
requests
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PublicContentRefreshServiceTests: XCTestCase {
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suite = "group.com.osgkeyboard.shared.tests.publicContent.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
|
||||
private var validCatalogData: Data {
|
||||
Data(
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"revision": 12,
|
||||
"generatedAt": "2026-08-21T03:00:00Z",
|
||||
"skills": [{
|
||||
"id": "official.rewrite",
|
||||
"systemImage": "wand.and.stars",
|
||||
"sortOrder": 10,
|
||||
"kind": "transform",
|
||||
"thinkingEnabled": true,
|
||||
"localizations": {
|
||||
"zh-Hans": {
|
||||
"name": "改写",
|
||||
"summary": "清晰改写剪贴板内容",
|
||||
"prompt": "请清晰改写剪贴板内容。"
|
||||
},
|
||||
"en": {
|
||||
"name": "Rewrite",
|
||||
"summary": "Rewrite clipboard text clearly",
|
||||
"prompt": "Rewrite the clipboard clearly."
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
}
|
||||
|
||||
func testOfficialForcedRefreshBypassesFreshnessAndUsesETag304() async throws {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let transport = QueuePublicContentTransport(
|
||||
responses: [
|
||||
PublicContentStubResponse(
|
||||
statusCode: 200,
|
||||
data: validCatalogData,
|
||||
headers: ["ETag": "\"skills-12\""]
|
||||
)
|
||||
]
|
||||
)
|
||||
let service = OfficialSkillCatalogRefreshService(
|
||||
store: store,
|
||||
transport: transport,
|
||||
endpointURL: URL(string: "https://example.test/v1/content/skills")!
|
||||
)
|
||||
let firstDate = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let first = await service.refreshIfNeeded(
|
||||
reason: "test",
|
||||
now: firstDate,
|
||||
force: true
|
||||
)
|
||||
XCTAssertEqual(first, .updated(revision: 12))
|
||||
XCTAssertEqual(store.officialSkillCatalog.etag, "\"skills-12\"")
|
||||
XCTAssertEqual(store.officialSkillCatalog.refreshedAt, firstDate)
|
||||
|
||||
await transport.append(
|
||||
PublicContentStubResponse(
|
||||
statusCode: 304,
|
||||
data: Data(),
|
||||
headers: ["ETag": "\"skills-12\""]
|
||||
)
|
||||
)
|
||||
// Sixty seconds remains inside ordinary freshness, but the Skills
|
||||
// manager's forced check must still revalidate with the cached ETag.
|
||||
let secondDate = firstDate.addingTimeInterval(60)
|
||||
let second = await service.refreshIfNeeded(
|
||||
reason: "test-304",
|
||||
now: secondDate,
|
||||
force: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(second, .notModified(revision: 12))
|
||||
XCTAssertEqual(store.officialSkillCatalog.refreshedAt, secondDate)
|
||||
XCTAssertEqual(store.officialSkillCatalog.skills.first?.id, "official.rewrite")
|
||||
let requests = await transport.recordedRequests()
|
||||
XCTAssertEqual(requests.count, 2)
|
||||
XCTAssertEqual(
|
||||
requests.last?.value(forHTTPHeaderField: "If-None-Match"),
|
||||
"\"skills-12\""
|
||||
)
|
||||
}
|
||||
|
||||
func testOfficialRefreshRejectsInvalidResponseAndKeepsCache() async throws {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
var cached = try JSONDecoder().decode(OfficialSkillCatalog.self, from: validCatalogData)
|
||||
cached.refreshedAt = Date(timeIntervalSince1970: 100)
|
||||
cached.etag = "\"good\""
|
||||
try store.setOfficialSkillCatalog(cached)
|
||||
let invalid = Data(
|
||||
"""
|
||||
{"schemaVersion":2,"revision":13,"skills":[]}
|
||||
""".utf8
|
||||
)
|
||||
let transport = QueuePublicContentTransport(
|
||||
responses: [
|
||||
PublicContentStubResponse(statusCode: 200, data: invalid, headers: [:])
|
||||
]
|
||||
)
|
||||
let service = OfficialSkillCatalogRefreshService(
|
||||
store: store,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
let outcome = await service.refreshIfNeeded(reason: "invalid", force: true)
|
||||
|
||||
XCTAssertEqual(outcome, .failed)
|
||||
XCTAssertEqual(store.officialSkillCatalog.revision, 12)
|
||||
XCTAssertEqual(store.officialSkillCatalog.etag, "\"good\"")
|
||||
}
|
||||
|
||||
func testOfficialRefreshUsesFreshCacheWithoutNetwork() async throws {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let refreshedAt = Date(timeIntervalSince1970: 1_000)
|
||||
var cached = try JSONDecoder().decode(OfficialSkillCatalog.self, from: validCatalogData)
|
||||
cached.refreshedAt = refreshedAt
|
||||
try store.setOfficialSkillCatalog(cached)
|
||||
let transport = QueuePublicContentTransport(responses: [])
|
||||
let service = OfficialSkillCatalogRefreshService(
|
||||
store: store,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
let outcome = await service.refreshIfNeeded(
|
||||
reason: "fresh-cache",
|
||||
now: refreshedAt.addingTimeInterval(60)
|
||||
)
|
||||
|
||||
XCTAssertEqual(outcome, .skippedFresh)
|
||||
XCTAssertEqual(OfficialSkillCatalogRefreshService.refreshInterval, 15 * 60)
|
||||
let requests = await transport.recordedRequests()
|
||||
XCTAssertTrue(requests.isEmpty)
|
||||
}
|
||||
|
||||
func testOfficialForcedRefreshDeduplicatesConcurrentFreshCacheRequests() async throws {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
var cached = try JSONDecoder().decode(OfficialSkillCatalog.self, from: validCatalogData)
|
||||
cached.refreshedAt = Date()
|
||||
cached.etag = "\"skills-12\""
|
||||
try store.setOfficialSkillCatalog(cached)
|
||||
let transport = QueuePublicContentTransport(
|
||||
responses: [
|
||||
PublicContentStubResponse(
|
||||
statusCode: 304,
|
||||
data: Data(),
|
||||
headers: [:]
|
||||
)
|
||||
],
|
||||
delay: .milliseconds(50)
|
||||
)
|
||||
let service = OfficialSkillCatalogRefreshService(
|
||||
store: store,
|
||||
transport: transport
|
||||
)
|
||||
|
||||
async let first = service.refreshIfNeeded(reason: "first", force: true)
|
||||
async let second = service.refreshIfNeeded(reason: "second", force: true)
|
||||
let outcomes = await (first, second)
|
||||
|
||||
XCTAssertEqual(outcomes.0, .notModified(revision: 12))
|
||||
XCTAssertEqual(outcomes.1, .notModified(revision: 12))
|
||||
let requests = await transport.recordedRequests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
func testHintRefreshUsesAccountPathsAndPreservesPacksOn304() async {
|
||||
let defaults = makeDefaults()
|
||||
let oldDate = Date(timeIntervalSince1970: 100)
|
||||
for locale in AIHintFeedEndpoints.supportedLocales {
|
||||
AIHintStore.saveReadyPack(
|
||||
AIHintPack(
|
||||
locale: locale,
|
||||
cards: [
|
||||
AIHintCard(
|
||||
id: "cached-\(locale)",
|
||||
displayText: "Cached",
|
||||
prompt: "Keep me",
|
||||
category: "general",
|
||||
locale: locale
|
||||
)
|
||||
],
|
||||
refreshedAt: oldDate
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
AIHintStore.setPackETag(
|
||||
"\"\(locale)-etag\"",
|
||||
locale: locale,
|
||||
defaults: defaults
|
||||
)
|
||||
}
|
||||
AIHintStore.setManifestETag("\"manifest-etag\"", defaults: defaults)
|
||||
let notModified = PublicContentStubResponse(
|
||||
statusCode: 304,
|
||||
data: Data(),
|
||||
headers: [:]
|
||||
)
|
||||
let transport = QueuePublicContentTransport(
|
||||
responses: [notModified, notModified, notModified]
|
||||
)
|
||||
let baseURL = URL(string: "https://account.osglab.com/v1/content/hints")!
|
||||
let service = AIHintRefreshService(
|
||||
transport: transport,
|
||||
defaults: defaults,
|
||||
baseURL: baseURL
|
||||
)
|
||||
let now = Date(timeIntervalSince1970: 5_000)
|
||||
|
||||
let outcome = await service.refreshNowIfNeeded(
|
||||
reason: "test-304",
|
||||
now: now,
|
||||
force: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(outcome, .completed(updatedLocales: ["zh", "en"]))
|
||||
XCTAssertEqual(
|
||||
AIHintStore.loadReadyPack(locale: "zh", defaults: defaults)?.cards.first?.prompt,
|
||||
"Keep me"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIHintStore.loadReadyPack(locale: "zh", defaults: defaults)?.refreshedAt,
|
||||
now
|
||||
)
|
||||
let requests = await transport.recordedRequests()
|
||||
XCTAssertEqual(requests.map { $0.url?.path }, [
|
||||
"/v1/content/hints/manifest",
|
||||
"/v1/content/hints/zh",
|
||||
"/v1/content/hints/en"
|
||||
])
|
||||
XCTAssertEqual(
|
||||
requests[1].value(forHTTPHeaderField: "If-None-Match"),
|
||||
"\"zh-etag\""
|
||||
)
|
||||
}
|
||||
|
||||
func testPublishedHintEndpointsUseAnonymousAccountContentRoutes() {
|
||||
XCTAssertEqual(
|
||||
AIHintFeedEndpoints.manifestURL.absoluteString,
|
||||
"https://account.osglab.com/v1/content/hints/manifest"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIHintFeedEndpoints.packURL(locale: "zh").absoluteString,
|
||||
"https://account.osglab.com/v1/content/hints/zh"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user