2cc81c4628
Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
322 lines
11 KiB
Swift
322 lines
11 KiB
Swift
// 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)
|
|
XCTAssertTrue(store.agentSkillLayout.isEnabled("official.rewrite"))
|
|
store.setAgentSkillLayout(
|
|
AIAgentSkillLayout(
|
|
enabledIDs: store.agentSkillLayout.enabledIDs.filter {
|
|
$0 != "official.rewrite"
|
|
},
|
|
confirmedShortcutIDs: store.agentSkillLayout.confirmedShortcutIDs
|
|
)
|
|
)
|
|
|
|
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")
|
|
XCTAssertFalse(store.agentSkillLayout.isEnabled("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"
|
|
)
|
|
}
|
|
}
|