Non-English users saw English text in a dozen places and blank labels in sixteen more. The English came from sites that produce their copy away from the widget that renders it, which is what the structural hardcoded-string check cannot see: picture-in-picture refused with a raw literal instead of the pipErrors.notSupported key that already existed; the two Jellyfin/Emby auth throws missing display: rendered their developer message on the add-server form; ServerParsingException.toString() fed its English into the localized "Failed to load servers" wrapper; Watch Together interpolated the whole PeerError, so a failed create read "Failed to create session: PeerError(PeerErrorType.timeout): Timed out creating session" and join printed its prefix twice; the hub and playlist continuation footers rendered exception.toString(); shader rows showed an English title over an already translated subtitle; the player queue fell back to the raw Dart enum name; a Plex home user with no title showed "Unknown"; a failed player start showed "Exception: Failed to initialize player"; and the tvOS top-shelf header was hardcoded in an extension that has no Flutter engine. The blanks came from three recent features that added English keys without translations. clean_translations.py filled all 21 siblings with empty strings, so the Android TV resolution switch, every Jellyfin/Emby recording-rule field, the demuxer row, the Companion Remote address caption and the Seerr blocklist pill rendered nothing at all. Two fixes are structural rather than key swaps. ContinuationStatusSliver now takes an errorContext and calls a new non-logging localizedLoadErrorText, so no future throw can leak through it. lib/mpv stays free of user-facing copy: it raises a PlayerInitializationException sentinel and a PlayerError.playerInitFailed cause tag that the player screen resolves to localized text. The tvOS section title travels in the shelf payload, additively, so an older cache still renders.
657 lines
21 KiB
Swift
657 lines
21 KiB
Swift
import Foundation
|
|
import Flutter
|
|
import XCTest
|
|
|
|
@testable import Runner
|
|
|
|
private final class ShelfArtworkURLProtocol: URLProtocol {
|
|
private static let lock = NSLock()
|
|
private static var startHandler: ((ShelfArtworkURLProtocol) -> Void)?
|
|
private static var stopHandler: (() -> Void)?
|
|
|
|
static func configure(
|
|
start: @escaping (ShelfArtworkURLProtocol) -> Void,
|
|
stop: (() -> Void)? = nil
|
|
) {
|
|
lock.lock()
|
|
startHandler = start
|
|
stopHandler = stop
|
|
lock.unlock()
|
|
}
|
|
|
|
static func reset() {
|
|
lock.lock()
|
|
startHandler = nil
|
|
stopHandler = nil
|
|
lock.unlock()
|
|
}
|
|
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
Self.lock.lock()
|
|
let handler = Self.startHandler
|
|
Self.lock.unlock()
|
|
guard let handler else {
|
|
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
|
|
return
|
|
}
|
|
handler(self)
|
|
}
|
|
|
|
override func stopLoading() {
|
|
Self.lock.lock()
|
|
let handler = Self.stopHandler
|
|
Self.lock.unlock()
|
|
handler?()
|
|
}
|
|
|
|
func sendResponse(contentLength: Int? = nil) {
|
|
var headers = ["Content-Type": "image/png"]
|
|
if let contentLength { headers["Content-Length"] = String(contentLength) }
|
|
let response = HTTPURLResponse(
|
|
url: request.url!,
|
|
statusCode: 200,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: headers
|
|
)!
|
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
}
|
|
|
|
func send(_ data: Data) {
|
|
client?.urlProtocol(self, didLoad: data)
|
|
}
|
|
|
|
func finish() {
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
}
|
|
|
|
private final class SystemShelfSyncHarness {
|
|
let defaults: UserDefaults
|
|
let root: URL
|
|
private(set) var state = SystemShelfMutationState()
|
|
private(set) var epoch: UInt64 = 0
|
|
|
|
private struct ScheduledPrune {
|
|
let deadline: TimeInterval
|
|
let batch: SystemShelfPruneBatch
|
|
}
|
|
|
|
private let suiteName: String
|
|
private var pruneClock: TimeInterval = 0
|
|
private var scheduledPrunes: [ScheduledPrune] = []
|
|
|
|
var now: () -> Date = { Date(timeIntervalSince1970: 1_000) }
|
|
var loaderShouldFail = false
|
|
private(set) var requestedURLs: [URL] = []
|
|
private(set) var notificationCount = 0
|
|
|
|
init() throws {
|
|
suiteName = "SystemShelfSyncHarness.\(UUID().uuidString)"
|
|
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
|
root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
epoch = state.beginEngineSession()
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
}
|
|
|
|
func cleanup() {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
try? FileManager.default.removeItem(at: root)
|
|
}
|
|
|
|
@discardableResult
|
|
func beginEngineSession() -> UInt64 {
|
|
epoch = state.beginEngineSession()
|
|
return epoch
|
|
}
|
|
|
|
func sync(
|
|
generation: Int64,
|
|
items: [[String: Any]],
|
|
ownerId: String = "profile-a",
|
|
engineEpoch: UInt64? = nil,
|
|
sectionTitle: String? = nil
|
|
) -> Bool {
|
|
let environment = SystemShelfSyncEnvironment(
|
|
defaults: defaults,
|
|
artworkRoot: root,
|
|
now: now,
|
|
loadArtwork: { [unowned self] url, _, _ in
|
|
requestedURLs.append(url)
|
|
guard !loaderShouldFail else { return nil }
|
|
return BoundedArtworkDownload(data: Self.png, mimeType: "image/png")
|
|
},
|
|
schedulePrune: { [unowned self] batches in
|
|
scheduledPrunes.append(
|
|
contentsOf: batches.map {
|
|
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
|
|
}
|
|
)
|
|
},
|
|
notifyChange: { [unowned self] in
|
|
notificationCount += 1
|
|
}
|
|
)
|
|
return SystemShelfPlugin.sync(
|
|
envelope: envelope(
|
|
generation: generation,
|
|
ownerId: ownerId,
|
|
engineEpoch: engineEpoch
|
|
),
|
|
rawItems: items,
|
|
sectionTitle: sectionTitle,
|
|
state: &state,
|
|
environment: environment
|
|
)
|
|
}
|
|
|
|
func clear(generation: Int64, ownerId: String = "profile-a") -> Bool {
|
|
SystemShelfPlugin.clearCache(
|
|
envelope: envelope(generation: generation, ownerId: ownerId),
|
|
state: &state,
|
|
defaults: defaults,
|
|
artworkRoot: root,
|
|
notifyChange: { [unowned self] in
|
|
notificationCount += 1
|
|
}
|
|
)
|
|
}
|
|
|
|
func remove(generation: Int64, contentId: String) -> Bool {
|
|
SystemShelfPlugin.removeItem(
|
|
envelope: envelope(generation: generation),
|
|
contentId: contentId,
|
|
state: &state,
|
|
defaults: defaults,
|
|
artworkRoot: root,
|
|
schedulePrune: { [unowned self] batches in
|
|
scheduledPrunes.append(
|
|
contentsOf: batches.map {
|
|
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
|
|
}
|
|
)
|
|
},
|
|
notifyChange: { [unowned self] in
|
|
notificationCount += 1
|
|
}
|
|
)
|
|
}
|
|
|
|
func envelope(
|
|
generation: Int64,
|
|
ownerId: String = "profile-a",
|
|
engineEpoch: UInt64? = nil
|
|
) -> SystemShelfMutationEnvelope {
|
|
SystemShelfMutationEnvelope(
|
|
ownerId: ownerId,
|
|
generation: generation,
|
|
engineEpoch: engineEpoch ?? epoch
|
|
)
|
|
}
|
|
|
|
func currentItems() throws -> [[String: Any]] {
|
|
let data = try XCTUnwrap(defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
|
let payload = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
)
|
|
let sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
|
return sections.flatMap { $0["items"] as? [[String: Any]] ?? [] }
|
|
}
|
|
|
|
func currentSectionTitles() throws -> [String] {
|
|
let data = try XCTUnwrap(defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
|
let payload = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
)
|
|
let sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
|
return sections.compactMap { $0["title"] as? String }
|
|
}
|
|
|
|
func artworkURL(for key: String) -> URL? {
|
|
guard
|
|
let enumerator = FileManager.default.enumerator(
|
|
at: root,
|
|
includingPropertiesForKeys: nil
|
|
)
|
|
else { return nil }
|
|
return enumerator.compactMap { $0 as? URL }.first { $0.lastPathComponent == key }
|
|
}
|
|
|
|
func advancePrunes(by interval: TimeInterval) {
|
|
pruneClock += interval
|
|
let due = scheduledPrunes.filter { $0.deadline <= pruneClock }
|
|
scheduledPrunes.removeAll { $0.deadline <= pruneClock }
|
|
for scheduled in due {
|
|
let candidates = state.claimPruning(scheduled.batch)
|
|
SystemShelfPlugin.pruneUnreferenced(
|
|
candidates,
|
|
defaults: defaults,
|
|
root: root
|
|
)
|
|
}
|
|
}
|
|
|
|
static func item(
|
|
source: String? = nil,
|
|
progress: Int = 0,
|
|
contentId: String = "movie-1"
|
|
) -> [String: Any] {
|
|
var item: [String: Any] = [
|
|
"contentId": contentId,
|
|
"title": "Movie",
|
|
"lastPlaybackPosition": progress,
|
|
]
|
|
if let source {
|
|
item["posterSourceUri"] = source
|
|
}
|
|
return item
|
|
}
|
|
|
|
static let png = Data(
|
|
base64Encoded:
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
|
)!
|
|
}
|
|
|
|
final class SystemShelfPluginTests: XCTestCase {
|
|
override func tearDown() {
|
|
ShelfArtworkURLProtocol.reset()
|
|
super.tearDown()
|
|
}
|
|
|
|
func testSyncRemoveAndClearUpdateCommittedShelf() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [
|
|
SystemShelfSyncHarness.item(
|
|
source: "https://shelf.test/a.png",
|
|
contentId: "movie-a"
|
|
),
|
|
SystemShelfSyncHarness.item(
|
|
source: "https://shelf.test/b.png",
|
|
contentId: "movie-b"
|
|
),
|
|
]
|
|
)
|
|
)
|
|
let initialItems = try harness.currentItems()
|
|
XCTAssertEqual(Set(initialItems.compactMap { $0["contentId"] as? String }), ["movie-a", "movie-b"])
|
|
let removedKey = try XCTUnwrap(
|
|
initialItems.first { $0["contentId"] as? String == "movie-a" }?["artworkKey"] as? String
|
|
)
|
|
let removedArtwork = try XCTUnwrap(harness.artworkURL(for: removedKey))
|
|
|
|
XCTAssertTrue(harness.remove(generation: 2, contentId: "movie-a"))
|
|
XCTAssertEqual(try harness.currentItems().compactMap { $0["contentId"] as? String }, ["movie-b"])
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: removedArtwork.path))
|
|
harness.advancePrunes(by: 60)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: removedArtwork.path))
|
|
|
|
XCTAssertTrue(harness.clear(generation: 3))
|
|
XCTAssertNil(harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: harness.root.path))
|
|
XCTAssertEqual(harness.notificationCount, 3)
|
|
}
|
|
|
|
func testSyncPersistsLocalizedSectionTitleWithEnglishFallback() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item()],
|
|
sectionTitle: "Fortsett å se"
|
|
)
|
|
)
|
|
XCTAssertEqual(try harness.currentSectionTitles(), ["Fortsett å se"])
|
|
|
|
// Absent or empty titles fall back to the English literal so an older
|
|
// app build keeps rendering a labelled shelf.
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 2,
|
|
items: [SystemShelfSyncHarness.item()],
|
|
sectionTitle: ""
|
|
)
|
|
)
|
|
XCTAssertEqual(try harness.currentSectionTitles(), ["Continue Watching"])
|
|
|
|
XCTAssertTrue(harness.sync(generation: 3, items: [SystemShelfSyncHarness.item()]))
|
|
XCTAssertEqual(try harness.currentSectionTitles(), ["Continue Watching"])
|
|
}
|
|
|
|
func testReplacementEngineRejectsStaleEngineMutation() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
let staleEpoch = harness.epoch
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 50,
|
|
items: [SystemShelfSyncHarness.item(progress: 50)]
|
|
)
|
|
)
|
|
_ = harness.beginEngineSession()
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item(progress: 1)],
|
|
ownerId: "profile-b"
|
|
)
|
|
)
|
|
|
|
XCTAssertFalse(
|
|
harness.sync(
|
|
generation: 51,
|
|
items: [SystemShelfSyncHarness.item(progress: 99)],
|
|
engineEpoch: staleEpoch
|
|
)
|
|
)
|
|
let item = try XCTUnwrap(harness.currentItems().first)
|
|
XCTAssertEqual(item["lastPlaybackPosition"] as? Int, 1)
|
|
let data = try XCTUnwrap(
|
|
harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
|
|
)
|
|
let payload = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
)
|
|
XCTAssertEqual(payload["ownerId"] as? String, "profile-b")
|
|
}
|
|
|
|
func testHigherGenerationRemainsLastWriter() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 2,
|
|
items: [SystemShelfSyncHarness.item(progress: 20)]
|
|
)
|
|
)
|
|
XCTAssertFalse(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item(progress: 10)]
|
|
)
|
|
)
|
|
XCTAssertEqual(
|
|
try harness.currentItems().first?["lastPlaybackPosition"] as? Int,
|
|
20
|
|
)
|
|
XCTAssertEqual(harness.notificationCount, 1)
|
|
}
|
|
|
|
func testProgressOnlySyncReusesCommittedArtworkWithoutRequestingItAgain() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
let source = "https://shelf.test/movie-a.png"
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item(source: source, progress: 10)]
|
|
)
|
|
)
|
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 2,
|
|
items: [SystemShelfSyncHarness.item(source: source, progress: 45)]
|
|
)
|
|
)
|
|
let updatedItem = try XCTUnwrap(harness.currentItems().first)
|
|
XCTAssertEqual(updatedItem["lastPlaybackPosition"] as? Int, 45)
|
|
XCTAssertEqual(updatedItem["artworkKey"] as? String, firstKey)
|
|
XCTAssertEqual(harness.requestedURLs.map(\.absoluteString), [source])
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
|
}
|
|
|
|
func testFailedReplacementKeepsLastCommittedArtwork() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
let firstSource = "https://shelf.test/movie-a.png"
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item(source: firstSource)]
|
|
)
|
|
)
|
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
|
|
|
harness.loaderShouldFail = true
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 2,
|
|
items: [
|
|
SystemShelfSyncHarness.item(source: "https://shelf.test/movie-b.png", progress: 20)
|
|
]
|
|
)
|
|
)
|
|
|
|
let item = try XCTUnwrap(harness.currentItems().first)
|
|
XCTAssertEqual(item["artworkKey"] as? String, firstKey)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
|
XCTAssertEqual(
|
|
harness.requestedURLs.map(\.absoluteString),
|
|
[firstSource, "https://shelf.test/movie-b.png"]
|
|
)
|
|
}
|
|
|
|
func testEachSupersededArtworkReceivesFullPruneGrace() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
|
|
)
|
|
)
|
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 2,
|
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
|
|
)
|
|
)
|
|
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
|
|
|
|
harness.advancePrunes(by: 59)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 3,
|
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/c.png")]
|
|
)
|
|
)
|
|
let currentKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let currentArtwork = try XCTUnwrap(harness.artworkURL(for: currentKey))
|
|
|
|
harness.advancePrunes(by: 1)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: firstArtwork.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
|
|
harness.advancePrunes(by: 58)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
|
|
harness.advancePrunes(by: 1)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: currentArtwork.path))
|
|
}
|
|
|
|
func testPruneRechecksCurrentArtworkAndRelaunchRecoveryPreservesIt() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 1,
|
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
|
|
)
|
|
)
|
|
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
|
|
XCTAssertTrue(
|
|
harness.sync(
|
|
generation: 2,
|
|
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
|
|
)
|
|
)
|
|
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
|
|
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
|
|
|
|
try replaceCommittedArtworkKey(
|
|
in: harness.defaults,
|
|
with: firstKey
|
|
)
|
|
harness.advancePrunes(by: 60)
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
|
|
|
let relaunchedAt = Date(timeIntervalSince1970: 10_000)
|
|
let recovered = SystemShelfPlugin.recoverableArtwork(
|
|
root: harness.root,
|
|
keeping: [firstArtwork],
|
|
now: relaunchedAt
|
|
)
|
|
XCTAssertEqual(
|
|
recovered[secondArtwork],
|
|
relaunchedAt.addingTimeInterval(60)
|
|
)
|
|
XCTAssertNil(recovered[firstArtwork])
|
|
|
|
var relaunchedState = SystemShelfMutationState()
|
|
_ = relaunchedState.beginEngineSession()
|
|
XCTAssertNotNil(relaunchedState.adoptPersistedOwner("profile-a"))
|
|
relaunchedState.cancelPruning(keeping: [firstArtwork])
|
|
let batches = relaunchedState.preparePruning(
|
|
removing: Set(recovered.keys),
|
|
after: 60
|
|
)
|
|
XCTAssertEqual(batches.count, 1)
|
|
XCTAssertEqual(batches.first?.delay, 60)
|
|
let candidates = relaunchedState.claimPruning(try XCTUnwrap(batches.first))
|
|
SystemShelfPlugin.pruneUnreferenced(
|
|
candidates,
|
|
defaults: harness.defaults,
|
|
root: harness.root
|
|
)
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
|
|
}
|
|
|
|
func testPruneDoesNotEscapeArtworkRoot() throws {
|
|
let harness = try SystemShelfSyncHarness()
|
|
defer { harness.cleanup() }
|
|
let outside = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("\(UUID().uuidString).art")
|
|
let inside = harness.root.appendingPathComponent("orphan.art")
|
|
let outsideDirectory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let outsideViaSymlink = outsideDirectory.appendingPathComponent("linked.art")
|
|
let linkedDirectory = harness.root.appendingPathComponent("linked", isDirectory: true)
|
|
let linkedCandidate = linkedDirectory.appendingPathComponent("linked.art")
|
|
try Data([0x01]).write(to: outside)
|
|
try Data([0x02]).write(to: inside)
|
|
try FileManager.default.createDirectory(
|
|
at: outsideDirectory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
try Data([0x03]).write(to: outsideViaSymlink)
|
|
try FileManager.default.createSymbolicLink(
|
|
at: linkedDirectory,
|
|
withDestinationURL: outsideDirectory
|
|
)
|
|
defer {
|
|
try? FileManager.default.removeItem(at: outside)
|
|
try? FileManager.default.removeItem(at: outsideDirectory)
|
|
}
|
|
|
|
SystemShelfPlugin.pruneUnreferenced(
|
|
[outside, inside, linkedCandidate],
|
|
defaults: harness.defaults,
|
|
root: harness.root
|
|
)
|
|
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: outside.path))
|
|
XCTAssertTrue(FileManager.default.fileExists(atPath: outsideViaSymlink.path))
|
|
XCTAssertFalse(FileManager.default.fileExists(atPath: inside.path))
|
|
}
|
|
|
|
func testArtworkLoaderCancelsUnknownLengthBodyAtConfiguredCap() {
|
|
let stopped = expectation(description: "oversized request cancelled")
|
|
ShelfArtworkURLProtocol.configure(
|
|
start: { request in
|
|
request.sendResponse()
|
|
request.send(Data(repeating: 0x41, count: 5))
|
|
request.send(Data(repeating: 0x42, count: 5))
|
|
request.finish()
|
|
},
|
|
stop: { stopped.fulfill() }
|
|
)
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
|
|
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
|
|
|
|
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
|
|
|
|
XCTAssertNil(result)
|
|
XCTAssertTrue(loader.exceededLimit)
|
|
XCTAssertLessThanOrEqual(loader.peakBufferedBytes, 8)
|
|
wait(for: [stopped], timeout: 1)
|
|
}
|
|
|
|
func testArtworkLoaderReturnsBoundedChunkedBodyAndMimeType() {
|
|
let expected = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])
|
|
ShelfArtworkURLProtocol.configure { request in
|
|
request.sendResponse()
|
|
request.send(expected.prefix(3))
|
|
request.send(expected.suffix(4))
|
|
request.finish()
|
|
}
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
|
|
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
|
|
|
|
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
|
|
|
|
XCTAssertEqual(result?.data, expected)
|
|
XCTAssertEqual(result?.mimeType, "image/png")
|
|
XCTAssertEqual(loader.peakBufferedBytes, expected.count)
|
|
XCTAssertFalse(loader.exceededLimit)
|
|
}
|
|
|
|
private func replaceCommittedArtworkKey(
|
|
in defaults: UserDefaults,
|
|
with key: String
|
|
) throws {
|
|
let data = try XCTUnwrap(
|
|
defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
|
|
)
|
|
var payload = try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
)
|
|
var sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
|
|
var items = try XCTUnwrap(sections.first?["items"] as? [[String: Any]])
|
|
items[0]["artworkKey"] = key
|
|
sections[0]["items"] = items
|
|
payload["sections"] = sections
|
|
defaults.set(
|
|
try JSONSerialization.data(withJSONObject: payload),
|
|
forKey: SystemShelfPlugin.cacheDataKey
|
|
)
|
|
}
|
|
}
|