Files
OSGKeyboard/OSGKeyboardTests/PublicContentRefreshServiceTests.swift
T
Rocky 2cc81c4628 feat(keyboard): expand contextual skills and managed flows
Add local clipboard intent recommendations, webpage and phone actions, and safer host handoffs. Refine managed gateway, catalog refresh, onboarding, and adaptive polish behavior.
2026-08-23 14:02:33 +08:00

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