From 691eaa92d0f78132449dbb86afc59b637ce76ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Menu?= Date: Fri, 28 Aug 2026 12:00:01 +0200 Subject: [PATCH] Make converting a `Link` to a `Locator` synchronous --- CHANGELOG.md | 6 + .../Navigator/Audiobook/AudioNavigator.swift | 2 +- .../EPUB/EPUBNavigatorViewController.swift | 6 +- .../EPUBViewportAndLocationCalculator.swift | 18 +- .../PDF/PDFNavigatorViewController.swift | 2 +- Sources/Shared/Publication/Manifest.swift | 27 + Sources/Shared/Publication/Publication.swift | 13 + .../PublicationContentIterator.swift | 6 +- .../Locator/DefaultLocatorService.swift | 22 +- .../Services/Locator/LocatorService.swift | 12 - .../Audio/Services/AudioLocatorService.swift | 11 +- .../Common/Outline/OutlineTableView.swift | 6 +- .../Common/VisualReaderViewController.swift | 10 +- ...UBViewportAndLocationCalculatorTests.swift | 352 ++++++------ .../Publication/ManifestTests.swift | 341 +++++++----- .../Publication/PublicationTests.swift | 524 +++++++++--------- .../Locator/DefaultLocatorServiceTests.swift | 71 --- 17 files changed, 741 insertions(+), 688 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 481bcf276f..a3ad6466da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. Take a look ## [Unreleased] +### Changed + +#### Shared + +* Converting a `Link` to a `Locator` is now synchronous: `await publication.locate(link)` becomes `publication.locator(for: link)`. The logic moved to `Manifest`, so it is also available as `manifest.locator(for: link)` without a `Publication`. + ### Fixed #### Navigator diff --git a/Sources/Navigator/Audiobook/AudioNavigator.swift b/Sources/Navigator/Audiobook/AudioNavigator.swift index 96f02d9307..7f268bb7d8 100644 --- a/Sources/Navigator/Audiobook/AudioNavigator.swift +++ b/Sources/Navigator/Audiobook/AudioNavigator.swift @@ -519,7 +519,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo } public func go(to link: Link, options: NavigatorGoOptions) async -> Bool { - guard let locator = await publication.locate(link) else { + guard let locator = publication.locator(for: link) else { return false } return await go(to: locator, options: options) diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 572c295024..5c21f07cd5 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -666,10 +666,10 @@ open class EPUBNavigatorViewController: InputObservableViewController, let (locator, viewport) = await EPUBViewportAndLocationCalculator.compute( readingOrderIndices: spreadView.spread.readingOrderIndices, progression: { spreadView.progression(in: $0) }, + manifest: publication.manifest, readingOrder: readingOrder, positionsByReadingOrder: positionsByReadingOrder, - tableOfContentsTitleByHref: tableOfContentsTitleByHref, - fallbackLocator: { [publication] in await publication.locate($0) } + tableOfContentsTitleByHref: tableOfContentsTitleByHref ) return (locator, viewport) } @@ -727,7 +727,7 @@ open class EPUBNavigatorViewController: InputObservableViewController, } public func go(to link: Link, options: NavigatorGoOptions) async -> Bool { - guard let locator = await publication.locate(link) else { + guard let locator = publication.locator(for: link) else { return false } return await go(to: locator, options: options) diff --git a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift index 1320dd3ffb..4bdbaef7a1 100644 --- a/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift +++ b/Sources/Navigator/EPUB/EPUBViewportAndLocationCalculator.swift @@ -9,7 +9,6 @@ import ReadiumShared /// Computes the current `Locator` and `Viewport` from a spread's visible /// progressions and the publication's position list. -@MainActor enum EPUBViewportAndLocationCalculator { /// Computes the locator and viewport for the currently visible spread. /// @@ -20,22 +19,23 @@ enum EPUBViewportAndLocationCalculator { /// - progression: Returns the visible scroll progression range (0–1) /// for a given reading-order index. For fixed-layout resources this /// is always `0...1`. - /// - readingOrder: The publication's reading order links. + /// - manifest: The publication's manifest, used to build a basic locator + /// for the first visible link when no positions are available. + /// - readingOrder: The links displayed by the navigator, indexed by + /// `readingOrderIndices`. May differ from the manifest's reading order + /// when the navigator was given a custom one. /// - positionsByReadingOrder: Positions grouped by reading-order index. /// May be empty if the publication has no positions. /// - tableOfContentsTitleByHref: Mapping from resource URL to its table- /// of-contents title, used to populate `Locator.title`. - /// - fallbackLocator: Called with the first visible link when no - /// positions are available; should return a basic locator for that - /// link (e.g. from `Publication.locate(_:)`). static func compute( readingOrderIndices: ClosedRange, progression: (Int) -> ClosedRange, + manifest: Manifest, readingOrder: [Link], positionsByReadingOrder: [[Locator]], - tableOfContentsTitleByHref: [AnyURL: String], - fallbackLocator: (Link) async -> Locator? - ) async -> (locator: Locator?, viewport: NavigatorViewport) { + tableOfContentsTitleByHref: [AnyURL: String] + ) -> (locator: Locator?, viewport: NavigatorViewport) { let firstIndex = readingOrderIndices.lowerBound let lastIndex = readingOrderIndices.upperBound let firstProgressionInFirstResource = min(max(progression(firstIndex).lowerBound, 0.0), 1.0) @@ -117,7 +117,7 @@ enum EPUBViewportAndLocationCalculator { return (locator, viewport) } else { - locator = await fallbackLocator(link)?.copy( + locator = manifest.locator(for: link)?.copy( locations: { $0.progression = firstProgressionInFirstResource } ) diff --git a/Sources/Navigator/PDF/PDFNavigatorViewController.swift b/Sources/Navigator/PDF/PDFNavigatorViewController.swift index 73906ac7ac..1002968971 100644 --- a/Sources/Navigator/PDF/PDFNavigatorViewController.swift +++ b/Sources/Navigator/PDF/PDFNavigatorViewController.swift @@ -728,7 +728,7 @@ open class PDFNavigatorViewController: } public func go(to link: Link, options: NavigatorGoOptions) async -> Bool { - guard let locator = await publication.locate(link) else { + guard let locator = publication.locator(for: link) else { return false } diff --git a/Sources/Shared/Publication/Manifest.swift b/Sources/Shared/Publication/Manifest.swift index 7022711bb9..321bbf2918 100644 --- a/Sources/Shared/Publication/Manifest.swift +++ b/Sources/Shared/Publication/Manifest.swift @@ -171,4 +171,31 @@ public struct Manifest: Hashable, Sendable, JSONValueDecodable, JSONObjectEncoda public func linksMatching(_ predicate: (Link) -> Bool) -> [Link] { (readingOrder + resources + links).filter(predicate) } + + /// Creates a new `Locator` pointing to the resource targeted by the given + /// `link`. + /// + /// Returns `nil` if the resource is not found in the manifest. + public func locator(for link: Link) -> Locator? { + let originalHREF = link.url() + let fragment = originalHREF.fragment + let href = originalHREF.removingFragment() + + guard + let resourceLink = linkWithHREF(href), + let mediaType = resourceLink.mediaType + else { + return nil + } + + return Locator( + href: href, + mediaType: mediaType, + title: resourceLink.title ?? link.title, + locations: Locator.Locations( + fragments: Array(ofNotNil: fragment), + progression: (fragment == nil) ? 0.0 : nil + ) + ) + } } diff --git a/Sources/Shared/Publication/Publication.swift b/Sources/Shared/Publication/Publication.swift index fabaa902a8..762e2d62a6 100644 --- a/Sources/Shared/Publication/Publication.swift +++ b/Sources/Shared/Publication/Publication.swift @@ -104,6 +104,19 @@ public final class Publication: Sendable, Loggable { manifest.linksWithRel(rel) } + /// Creates a new `Locator` pointing to the resource targeted by the given + /// `link`. + /// + /// Returns `nil` if the resource is not found in the publication. + public func locator(for link: Link) -> Locator? { + manifest.locator(for: link) + } + + @available(*, unavailable, renamed: "locator(for:)") + public func locate(_ link: Link) async -> Locator? { + fatalError() + } + /// Returns the resource targeted by the given `link`. public func get(_ link: Link) -> Resource? { assert(!link.templated, "You must expand templated links before calling `Publication.get`") diff --git a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift index d8a1bbbdb1..f6c68ca7ac 100644 --- a/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift +++ b/Sources/Shared/Publication/Services/Content/Iterators/PublicationContentIterator.swift @@ -126,7 +126,7 @@ public actor PublicationContentIterator: ContentIterator, Loggable { let link = publication.readingOrder[index] guard let resource = publication.get(link), - let locator = await location.toLocator(to: link, in: publication) + let locator = location.toLocator(to: link, in: publication) else { return nil } @@ -149,12 +149,12 @@ private enum LocatorOrProgression { case locator(Locator) case progression(Double) - func toLocator(to link: Link, in publication: Publication) async -> Locator? { + func toLocator(to link: Link, in publication: Publication) -> Locator? { switch self { case let .locator(locator): return locator case let .progression(progression): - return await publication.locate(link)?.copy(locations: { $0.progression = progression }) + return publication.locator(for: link)?.copy(locations: { $0.progression = progression }) } } } diff --git a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift index df4d8d151d..3dae07102a 100644 --- a/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/DefaultLocatorService.swift @@ -41,27 +41,9 @@ public final class DefaultLocatorService: Sendable, LocatorService, Loggable { return nil } + @available(*, unavailable, message: "Use `Publication.locator(for:)` instead.") public func locate(_ link: Link) async -> Locator? { - let originalHREF = link.url() - let fragment = originalHREF.fragment - let href = originalHREF.removingFragment() - - guard - let resourceLink = publication()?.linkWithHREF(href), - let type = resourceLink.mediaType - else { - return nil - } - - return Locator( - href: href, - mediaType: type, - title: resourceLink.title ?? link.title, - locations: Locator.Locations( - fragments: Array(ofNotNil: fragment), - progression: (fragment == nil) ? 0.0 : nil - ) - ) + fatalError() } public func locate(progression totalProgression: Double) async -> Locator? { diff --git a/Sources/Shared/Publication/Services/Locator/LocatorService.swift b/Sources/Shared/Publication/Services/Locator/LocatorService.swift index aad19b0362..932bbced70 100644 --- a/Sources/Shared/Publication/Services/Locator/LocatorService.swift +++ b/Sources/Shared/Publication/Services/Locator/LocatorService.swift @@ -19,9 +19,6 @@ public protocol LocatorService: PublicationService { /// Locates the target of the given `locator`. func locate(_ locator: Locator) async -> Locator? - /// Locates the target of the given `link`. - func locate(_ link: Link) async -> Locator? - /// Locates the target at the given `progression` relative to the whole publication. func locate(progression: Double) async -> Locator? } @@ -31,10 +28,6 @@ public extension LocatorService { nil } - func locate(_ link: Link) async -> Locator? { - nil - } - func locate(progression: Double) async -> Locator? { nil } @@ -52,11 +45,6 @@ public extension Publication { func locate(progression: Double) async -> Locator? { await findService(LocatorService.self)?.locate(progression: progression) } - - /// Locates the target of the given `link`. - func locate(_ link: Link) async -> Locator? { - await findService(LocatorService.self)?.locate(link) - } } // MARK: PublicationServicesBuilder Helpers diff --git a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift index bcc4220ccc..3ad9e1e30f 100644 --- a/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift +++ b/Sources/Streamer/Parser/Audio/Services/AudioLocatorService.swift @@ -27,8 +27,6 @@ final class AudioLocatorService: LocatorService { /// Total duration of the publication. private let totalDuration: Double? - private let locatorService: DefaultLocatorService - init(readingOrder: [Link], publication: Weak) { self.publication = publication self.readingOrder = readingOrder @@ -36,7 +34,6 @@ final class AudioLocatorService: LocatorService { self.durations = durations let total = durations.reduce(0, +) totalDuration = (total > 0) ? total : nil - locatorService = DefaultLocatorService(publication: publication) } func locate(_ locator: Locator) async -> Locator? { @@ -50,8 +47,8 @@ final class AudioLocatorService: LocatorService { // Routes the `totalProgression` fallback through this service's audio // `locate(progression:)`, which is duration-based. Delegating to - // `locatorService.locate(locator)` would instead use the default - // positions-based progression and lose the audio behavior. + // `DefaultLocatorService` would instead use the default positions-based + // progression and lose the audio behavior. if let totalProgression = locator.locations.totalProgression, let target = await locate(progression: totalProgression) @@ -65,10 +62,6 @@ final class AudioLocatorService: LocatorService { return nil } - func locate(_ link: Link) async -> Locator? { - await locatorService.locate(link) - } - func locate(progression: Double) async -> Locator? { guard let totalDuration = totalDuration else { return nil diff --git a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift index 64fe706967..1ab346a9bf 100644 --- a/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift +++ b/TestApp/Sources/Reader/Common/Outline/OutlineTableView.swift @@ -60,10 +60,8 @@ struct OutlineTableView: View { .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { - Task { - if let locator = await publication.locate(item.link) { - locatorSubject.send(locator) - } + if let locator = publication.locator(for: item.link) { + locatorSubject.send(locator) } } } diff --git a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift index ac48a1000f..4e319bd10a 100644 --- a/TestApp/Sources/Reader/Common/VisualReaderViewController.swift +++ b/TestApp/Sources/Reader/Common/VisualReaderViewController.swift @@ -362,10 +362,10 @@ class VisualReaderViewController: ReaderViewCon /// description of the image. private func openFromImagePreview(_ link: ReadiumShared.Link) { Task { - // `locate` runs first: the href is only inspected once it comes - // back nil, so a remote publication whose in-publication links are - // themselves absolute is not misrouted to the browser. - let locator = await publication.locate(link) + // `locator(for:)` runs first: the href is only inspected once it + // comes back nil, so a remote publication whose in-publication + // links are themselves absolute is not misrouted to the browser. + let locator = publication.locator(for: link) let externalURL = (locator == nil) ? link.httpURL : nil if locator == nil, externalURL == nil { log(.error, "Cannot locate the extended description at \(link.href)") @@ -416,7 +416,7 @@ extension VisualReaderViewController { for (index, link) in publication.pageList.enumerated() { guard let title = link.title, - let locator = await publication.locate(link) + let locator = publication.locator(for: link) else { continue } diff --git a/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift b/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift index 85609c5a8a..27ced20c38 100644 --- a/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift +++ b/Tests/NavigatorTests/EPUB/EPUBViewportAndLocationCalculatorTests.swift @@ -11,43 +11,44 @@ import Testing enum EPUBViewportAndLocationCalculatorTests { struct Viewport { @Test("builds resource list from a single-resource spread") - func singleResourceReadingOrder() async { - let ro = makeReadingOrder(count: 2) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func singleResourceReadingOrder() { + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.5 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) - #expect(viewport.resources.map(\.href) == [ro[0].url()]) + #expect(viewport.resources.map(\.href) == [manifest.readingOrder[0].url()]) } @Test("records progression range for the visible resource") - func progressionRange() async { - let ro = makeReadingOrder(count: 2) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func progressionRange() { + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.25 ... 0.75 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) - #expect(viewport.resources.first(where: { $0.href.string == ro[0].href })?.progression == 0.25 ... 0.75) + #expect(viewport.resources.first(where: { $0.href.string == manifest.readingOrder[0].href })?.progression == 0.25 ... 0.75) } @Test("includes both resources for a two-index spread") - func twoIndexSpread() async { - let ro = makeReadingOrder(count: 2) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func twoIndexSpread() { + let manifest = makeManifest(count: 2) + let ro = manifest.readingOrder + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 1, progression: { i in i == 0 ? 0.0 ... 1.0 : 0.0 ... 1.0 }, + manifest: manifest, readingOrder: ro, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(viewport.resources.map(\.href) == [ro[0].url(), ro[1].url()]) #expect(viewport.resources.first(where: { $0.href.string == ro[0].href })?.progression == 0.0 ... 1.0) @@ -55,14 +56,15 @@ enum EPUBViewportAndLocationCalculatorTests { } @Test("total progression range lower bound matches locator totalProgression") - func totalProgressionLowerBoundMatchesLocator() async { - let (locator, viewport) = await EPUBViewportAndLocationCalculator.compute( + func totalProgressionLowerBoundMatchesLocator() { + let manifest = makeManifest(count: 2) + let (locator, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.5 ... 0.75 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(viewport.progression.lowerBound == locator?.locations.totalProgression) } @@ -74,126 +76,134 @@ enum EPUBViewportAndLocationCalculatorTests { // resourceTotalProgressionEnd for resource 0 = 4/8 = 0.5. @Test("totalProgression at start of first resource is 0.0") - func totalProgressionAtStart() async { - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func totalProgressionAtStart() { + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(locator?.locations.totalProgression == 0.0) } @Test("totalProgression at end of first resource equals start of second") - func totalProgressionAtResourceBoundary() async { - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func totalProgressionAtResourceBoundary() { + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 1.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // Resource 0 ends where resource 1 begins: totalProgression = 4/8 = 0.5 #expect(locator?.locations.totalProgression == 0.5) } @Test("totalProgression at end of last resource is 1.0") - func totalProgressionAtEnd() async { - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func totalProgressionAtEnd() { + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 1 ... 1, progression: { _ in 1.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(locator?.locations.totalProgression == 1.0) } @Test("totalProgression interpolates linearly mid-resource") - func totalProgressionInterpolation() async { + func totalProgressionInterpolation() { // At 0.5 progression in resource 0: // totalProgression = 0.0 + 0.5 * (0.5 - 0.0) = 0.25 - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.5 ... 0.5 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(locator?.locations.totalProgression == 0.25) } @Test("progression field reflects actual scroll offset") - func progressionFieldIsScrollOffset() async { - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func progressionFieldIsScrollOffset() { + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.3 ... 0.7 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(locator?.locations.progression == 0.3) } @Test("position index is selected via ceil of resource progression") - func positionIndexViaCeil() async { + func positionIndexViaCeil() { // progression=0.5, 4 positions → ceil(0.5 * 3) = ceil(1.5) = 2 → position 3 (1-based) - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.5 ... 0.5 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // Index 2 in resource 0 has position number 3 (absolute index 2, 1-based) #expect(locator?.locations.position == 3) } @Test("position index at start of resource is 0") - func positionIndexAtStart() async { + func positionIndexAtStart() { // progression=0.0, 4 positions → ceil(0.0 * 3) = 0 - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // Index 0 in resource 0 has position number 1 #expect(locator?.locations.position == 1) } @Test("title is taken from tableOfContentsTitleByHref") - func titleFromTOC() async { - let ro = makeReadingOrder(count: 1) - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func titleFromTOC() { + let manifest = makeManifest(count: 1) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.0 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 1, positionsPerResource: 1), - tableOfContentsTitleByHref: [ro[0].url(): "Chapter One"], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [manifest.readingOrder[0].url(): "Chapter One"] ) #expect(locator?.title == "Chapter One") } @Test("title is nil when href not in table of contents") - func noTitle() async { - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func noTitle() { + let manifest = makeManifest(count: 1) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.0 }, - readingOrder: makeReadingOrder(count: 1), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 1, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(locator?.title == nil) } @@ -201,14 +211,15 @@ enum EPUBViewportAndLocationCalculatorTests { @Suite("Viewport positions - positions available") struct ViewportPositions { @Test("positions range is single position when viewing start of resource") - func singlePositionAtStart() async { - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func singlePositionAtStart() { + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // firstProgression=0.0 → positionIndex=0 → position 1 // lastProgression=0.0 → ceil(0.0*3)-1 = -1 → max(0,-1) = 0 → position 1 @@ -216,37 +227,39 @@ enum EPUBViewportAndLocationCalculatorTests { } @Test("positions range spans multiple positions when viewport shows a range") - func multiPositionRange() async { + func multiPositionRange() { // firstProgression=0.0 → firstPositionIndex=0 → position 1 // lastProgression=1.0 → lastPositionIndex=count-1=3 → position 4 - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(viewport.positions == 1 ... 4) } @Test("lastProgression == 1.0 uses last position index in resource") - func lastProgressionExactlyOne() async { + func lastProgressionExactlyOne() { + let manifest = makeManifest(count: 1) let positions = makePositions(resourceCount: 1, positionsPerResource: 4) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 1), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: positions, - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // lastPositionIndex = count - 1 = 3 → position 4 #expect(viewport.positions?.upperBound == 4) } @Test("lastProgression near 1.0 does not reach last position index") - func lastProgressionNearOne() async { + func lastProgressionNearOne() { // The last position index is only reached when lastProgression is // exactly 1.0 (handled by the special-case branch). For any value // strictly below 1.0 the formula is ceil(x * (count-1)) - 1, which @@ -254,15 +267,16 @@ enum EPUBViewportAndLocationCalculatorTests { // stopping one step short of the final position until the very end. // This prevents the position from jumping ahead before the reader // has fully scrolled into it. + let manifest = makeManifest(count: 1) let positions = makePositions(resourceCount: 1, positionsPerResource: 4) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, // upperBound is just below 1.0 but not exactly 1.0 progression: { _ in 0.0 ... 0.9999 }, - readingOrder: makeReadingOrder(count: 1), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: positions, - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // ceil(0.9999 * 3) - 1 = ceil(2.9997) - 1 = 3 - 1 = 2 → position 3 #expect(viewport.positions?.upperBound == 3) @@ -275,14 +289,15 @@ enum EPUBViewportAndLocationCalculatorTests { // Resource 1 total progression window: 0.5 … 1.0 @Test("progression lower bound is 0.0 when scrolled to start") - func progressionAtStart() async { - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func progressionAtStart() { + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 0.5 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // lower = 0.0 + 0.0 * 0.5 = 0.0 // upper = 0.0 + 0.5 * 0.5 = 0.25 @@ -291,14 +306,15 @@ enum EPUBViewportAndLocationCalculatorTests { } @Test("progression upper bound is 1.0 when scrolled to end of last resource") - func progressionAtEnd() async { - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func progressionAtEnd() { + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 1 ... 1, progression: { _ in 0.5 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 4), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) // lower = 0.5 + 0.5 * 0.5 = 0.75 // upper = 0.5 + 1.0 * 0.5 = 1.0 @@ -306,79 +322,97 @@ enum EPUBViewportAndLocationCalculatorTests { } @Test("progression spans both resources in a two-index FXL spread") - func progressionSpansBothResources() async { + func progressionSpansBothResources() { // Resource 0 visible fully, resource 1 visible fully. // lower = 0.0 (start of resource 0), upper = 1.0 (end of resource 1) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 1, progression: { _ in 0.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(viewport.progression == 0.0 ... 1.0) } } - @Suite("Locator - no positions (fallback)") struct Fallback { - @Test("uses fallback locator when positionsByReadingOrder is empty") - func useFallback() async { - let ro = makeReadingOrder(count: 1) - let fallback = Locator(href: ro[0].url(), mediaType: .html, title: "Fallback") - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + @Suite("Locator - no positions (manifest fallback)") struct Fallback { + @Test("builds the locator from the manifest when positionsByReadingOrder is empty") + func useFallback() { + let manifest = makeManifest(readingOrder: [ + Link(href: "chap1.html", mediaType: .html, title: "Fallback"), + ]) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.3 ... 0.3 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: [], - tableOfContentsTitleByHref: [:], - fallbackLocator: { _ in fallback } + tableOfContentsTitleByHref: [:] ) #expect(locator?.title == "Fallback") } @Test("progression is set on the fallback locator") - func fallbackProgressionIsSet() async { - let ro = makeReadingOrder(count: 1) - let fallback = Locator(href: ro[0].url(), mediaType: .html) - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func fallbackProgressionIsSet() { + let manifest = makeManifest(count: 1) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.42 ... 0.42 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: [], - tableOfContentsTitleByHref: [:], - fallbackLocator: { _ in fallback } + tableOfContentsTitleByHref: [:] ) #expect(locator?.locations.progression == 0.42) } @Test("uses fallback when positions array does not cover the current resource index") - func fallbackWhenPositionsMissingForResource() async { - let ro = makeReadingOrder(count: 3) - let fallback = Locator(href: ro[2].url(), mediaType: .html, title: "Chapter 3") - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + func fallbackWhenPositionsMissingForResource() { + let manifest = makeManifest(readingOrder: [ + Link(href: "chap1.html", mediaType: .html), + Link(href: "chap2.html", mediaType: .html), + Link(href: "chap3.html", mediaType: .html, title: "Chapter 3"), + ]) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 2 ... 2, progression: { _ in 0.5 ... 0.5 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, // positions only cover resources 0 and 1, not resource 2 positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: { _ in fallback } + tableOfContentsTitleByHref: [:] ) #expect(locator?.title == "Chapter 3") #expect(locator?.locations.progression == 0.5) } + @Test("locator is nil when the link is not in the manifest") + func unknownLink() { + let manifest = makeManifest(count: 1) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( + readingOrderIndices: 0 ... 0, + progression: { _ in 0.0 ... 1.0 }, + manifest: manifest, + readingOrder: [Link(href: "not-in-manifest.html", mediaType: .html)], + positionsByReadingOrder: [], + tableOfContentsTitleByHref: [:] + ) + #expect(locator == nil) + } + @Test("viewport.positions is nil when no positions available") - func viewportPositionsIsNil() async { - let ro = makeReadingOrder(count: 1) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func viewportPositionsIsNil() { + let manifest = makeManifest(count: 1) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 0, progression: { _ in 0.0 ... 1.0 }, - readingOrder: ro, + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: [], - tableOfContentsTitleByHref: [:], - fallbackLocator: { link in Locator(href: link.url(), mediaType: .html) } + tableOfContentsTitleByHref: [:] ) #expect(viewport.positions == nil) } @@ -386,45 +420,48 @@ enum EPUBViewportAndLocationCalculatorTests { @Suite("Two-index spread (FXL)") struct TwoIndexSpread { @Test("totalProgression is computed from the first resource's range") - func totalProgressionUsesFirstResource() async { + func totalProgressionUsesFirstResource() { // FXL: progression always returns 0...1, so firstProgression=0.0. // Resource 0 range: 0/2 = 0.0 … 1/2 = 0.5. // totalProgression = 0.0 + 0.0 * (0.5 - 0.0) = 0.0 - let (locator, _) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (locator, _) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 1, progression: { _ in 0.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(locator?.locations.totalProgression == 0.0) } @Test("viewport.positions spans both resources in a two-index FXL spread") - func viewportPositionsSpanBothResources() async { + func viewportPositionsSpanBothResources() { // Each FXL resource has one position; resource 0 → position 1, resource 1 → position 2. - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + let manifest = makeManifest(count: 2) + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 1, progression: { _ in 0.0 ... 1.0 }, - readingOrder: makeReadingOrder(count: 2), + manifest: manifest, + readingOrder: manifest.readingOrder, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(viewport.positions == 1 ... 2) } @Test("viewport resources contains an entry for each visible resource") - func viewportContainsBothResources() async { - let ro = makeReadingOrder(count: 2) - let (_, viewport) = await EPUBViewportAndLocationCalculator.compute( + func viewportContainsBothResources() { + let manifest = makeManifest(count: 2) + let ro = manifest.readingOrder + let (_, viewport) = EPUBViewportAndLocationCalculator.compute( readingOrderIndices: 0 ... 1, progression: { i in i == 0 ? 0.1 ... 0.9 : 0.2 ... 0.8 }, + manifest: manifest, readingOrder: ro, positionsByReadingOrder: makePositions(resourceCount: 2, positionsPerResource: 1), - tableOfContentsTitleByHref: [:], - fallbackLocator: noFallback + tableOfContentsTitleByHref: [:] ) #expect(viewport.resources.first(where: { $0.href.string == ro[0].href })?.progression == 0.1 ... 0.9) #expect(viewport.resources.first(where: { $0.href.string == ro[1].href })?.progression == 0.2 ... 0.8) @@ -434,9 +471,14 @@ enum EPUBViewportAndLocationCalculatorTests { // MARK: - Helpers -/// Builds a reading order of `count` links with hrefs "chap1.html", "chap2.html", … -private func makeReadingOrder(count: Int) -> [Link] { - (1 ... count).map { Link(href: "chap\($0).html", mediaType: .html) } +/// Builds a manifest whose reading order contains `count` links with hrefs +/// "chap1.html", "chap2.html", … +private func makeManifest(count: Int) -> Manifest { + makeManifest(readingOrder: (1 ... count).map { Link(href: "chap\($0).html", mediaType: .html) }) +} + +private func makeManifest(readingOrder: [Link]) -> Manifest { + Manifest(metadata: Metadata(title: ""), readingOrder: readingOrder) } /// Builds positions for `resourceCount` resources, each with `positionsPerResource` @@ -463,9 +505,3 @@ private func makePositions(resourceCount: Int, positionsPerResource: Int) -> [[L } } } - -/// A no-op fallback locator used when positions are available (fallback branch -/// should not be reached). -private func noFallback(_ link: Link) async -> Locator? { - nil -} diff --git a/Tests/SharedTests/Publication/ManifestTests.swift b/Tests/SharedTests/Publication/ManifestTests.swift index 73bfeb2e31..8530c82e8d 100644 --- a/Tests/SharedTests/Publication/ManifestTests.swift +++ b/Tests/SharedTests/Publication/ManifestTests.swift @@ -5,14 +5,12 @@ // @testable import ReadiumShared -import XCTest +import Testing -class ManifestTests: XCTestCase { - let fixtures = Fixtures(path: "Publication") - - func testParseMinimalJSON() { - XCTAssertEqual( - try? Manifest(json: [ +struct ManifestTests { + @Test func parseMinimalJSON() { + #expect( + (try? Manifest(json: [ "metadata": ["title": "Title"], "links": [ ["href": "manifest.json", "rel": "self"], @@ -20,18 +18,18 @@ class ManifestTests: XCTestCase { "readingOrder": [ ["href": "chap1.html", "type": "text/html"], ], - ] as JSONValue), - Manifest( - metadata: Metadata(title: "Title"), - links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", mediaType: .html)] - ) + ] as JSONValue)) == + Manifest( + metadata: Metadata(title: "Title"), + links: [Link(href: "manifest.json", rels: [.self])], + readingOrder: [Link(href: "chap1.html", mediaType: .html)] + ) ) } - func testParseFullJSON() { - XCTAssertEqual( - try? Manifest(json: [ + @Test func parseFullJSON() { + #expect( + (try? Manifest(json: [ "@context": "https://readium.org/webpub-manifest/context.jsonld", "metadata": ["title": "Title"], "links": [ @@ -52,22 +50,22 @@ class ManifestTests: XCTestCase { ["href": "sublink"], ], ], - ] as JSONValue), - Manifest( - context: ["https://readium.org/webpub-manifest/context.jsonld"], - metadata: Metadata(title: "Title"), - links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", mediaType: .html)], - resources: [Link(href: "image.png", mediaType: .png)], - tableOfContents: [Link(href: "cover.html"), Link(href: "chap1.html")], - subcollections: ["sub": [PublicationCollection(links: [Link(href: "sublink")])]] - ) + ] as JSONValue)) == + Manifest( + context: ["https://readium.org/webpub-manifest/context.jsonld"], + metadata: Metadata(title: "Title"), + links: [Link(href: "manifest.json", rels: [.self])], + readingOrder: [Link(href: "chap1.html", mediaType: .html)], + resources: [Link(href: "image.png", mediaType: .png)], + tableOfContents: [Link(href: "cover.html"), Link(href: "chap1.html")], + subcollections: ["sub": [PublicationCollection(links: [Link(href: "sublink")])]] + ) ) } - func testParseContextAsArray() { - XCTAssertEqual( - try? Manifest(json: [ + @Test func parseContextAsArray() { + #expect( + (try? Manifest(json: [ "@context": ["context1", "context2"], "metadata": ["title": "Title"], "links": [ @@ -76,35 +74,39 @@ class ManifestTests: XCTestCase { "readingOrder": [ ["href": "chap1.html", "type": "text/html"], ], - ] as JSONValue), - Manifest( - context: ["context1", "context2"], - metadata: Metadata(title: "Title"), - links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", mediaType: .html)] - ) + ] as JSONValue)) == + Manifest( + context: ["context1", "context2"], + metadata: Metadata(title: "Title"), + links: [Link(href: "manifest.json", rels: [.self])], + readingOrder: [Link(href: "chap1.html", mediaType: .html)] + ) ) } - func testParseInvalidJSON() { - XCTAssertThrowsError(try Manifest(json: "")) + @Test func parseInvalidJSON() { + #expect(throws: JSONError.self) { + try Manifest(json: "") + } } - func testParseJSONRequiresMetadata() { - XCTAssertThrowsError(try Manifest(json: [ - "links": [ - ["href": "manifest.json", "rel": "self"], - ], - "readingOrder": [ - ["href": "chap1.html", "type": "text/html"], - ], - ])) + @Test func parseJSONRequiresMetadata() { + #expect(throws: (any Error).self) { + try Manifest(json: [ + "links": [ + ["href": "manifest.json", "rel": "self"], + ], + "readingOrder": [ + ["href": "chap1.html", "type": "text/html"], + ], + ]) + } } - func testParseJSONSpineAsReadingOrder() { + @Test func parseJSONSpineAsReadingOrder() { // `readingOrder` used to be `spine`, so we parse `spine` as a fallback. - XCTAssertEqual( - try? Manifest(json: [ + #expect( + (try? Manifest(json: [ "metadata": ["title": "Title"], "links": [ ["href": "manifest.json", "rel": "self"], @@ -112,17 +114,17 @@ class ManifestTests: XCTestCase { "spine": [ ["href": "chap1.html", "type": "text/html"], ], - ] as JSONValue), - Manifest( - metadata: Metadata(title: "Title"), - links: [Link(href: "manifest.json", rels: [.self])], - readingOrder: [Link(href: "chap1.html", mediaType: .html)] - ) + ] as JSONValue)) == + Manifest( + metadata: Metadata(title: "Title"), + links: [Link(href: "manifest.json", rels: [.self])], + readingOrder: [Link(href: "chap1.html", mediaType: .html)] + ) ) } - func testParseJSONIgnoresReadingOrderWithoutType() { - XCTAssertEqual( + @Test func parseJSONIgnoresReadingOrderWithoutType() throws { + #expect( try Manifest(json: [ "metadata": ["title": "Title"], "links": [ @@ -132,19 +134,19 @@ class ManifestTests: XCTestCase { ["href": "chap1.html", "type": "text/html"], ["href": "chap2.html"], ], - ] as JSONValue), - Manifest( - metadata: Metadata(title: "Title"), - links: [ - Link(href: "manifest.json", rels: [.self]), - ], - readingOrder: [Link(href: "chap1.html", mediaType: .html)] - ) + ] as JSONValue) == + Manifest( + metadata: Metadata(title: "Title"), + links: [ + Link(href: "manifest.json", rels: [.self]), + ], + readingOrder: [Link(href: "chap1.html", mediaType: .html)] + ) ) } - func testParseJSONIgnoresRessourcesWithoutType() { - XCTAssertEqual( + @Test func parseJSONIgnoresRessourcesWithoutType() throws { + #expect( try Manifest(json: [ "metadata": ["title": "Title"], "links": [ @@ -157,39 +159,39 @@ class ManifestTests: XCTestCase { ["href": "withtype", "type": "text/html"], ["href": "withouttype"], ], - ] as JSONValue), - Manifest( - metadata: Metadata(title: "Title"), - links: [ - Link(href: "manifest.json", rels: [.self]), - ], - readingOrder: [Link(href: "chap1.html", mediaType: .html)], - resources: [Link(href: "withtype", mediaType: .html)] - ) + ] as JSONValue) == + Manifest( + metadata: Metadata(title: "Title"), + links: [ + Link(href: "manifest.json", rels: [.self]), + ], + readingOrder: [Link(href: "chap1.html", mediaType: .html)], + resources: [Link(href: "withtype", mediaType: .html)] + ) ) } - func testGetMinimalJSON() { - XCTAssertEqual( + @Test func getMinimalJSON() { + #expect( Manifest( metadata: Metadata(title: "Title"), links: [Link(href: "manifest.json", rels: [.self])], readingOrder: [Link(href: "chap1.html", mediaType: .html)] - ).jsonObject, - [ - "metadata": ["title": "Title", "readingProgression": "auto"], - "links": [ - ["href": "manifest.json", "rel": ["self"], "templated": false] as JSONValue, - ], - "readingOrder": [ - ["href": "chap1.html", "type": "text/html", "templated": false] as JSONValue, - ], - ] as [String: JSONValue] + ).jsonObject == + [ + "metadata": ["title": "Title", "readingProgression": "auto"], + "links": [ + ["href": "manifest.json", "rel": ["self"], "templated": false] as JSONValue, + ], + "readingOrder": [ + ["href": "chap1.html", "type": "text/html", "templated": false] as JSONValue, + ], + ] as [String: JSONValue] ) } - func testGetFullJSON() { - XCTAssertEqual( + @Test func getFullJSON() { + #expect( Manifest( context: ["https://readium.org/webpub-manifest/context.jsonld"], metadata: Metadata(title: "Title"), @@ -198,64 +200,61 @@ class ManifestTests: XCTestCase { resources: [Link(href: "image.png", mediaType: .png)], tableOfContents: [Link(href: "cover.html"), Link(href: "chap1.html")], subcollections: ["sub": [PublicationCollection(links: [Link(href: "sublink")])]] - ).jsonObject, - [ - "@context": ["https://readium.org/webpub-manifest/context.jsonld"], - "metadata": ["title": "Title", "readingProgression": "auto"], - "links": [ - ["href": "manifest.json", "rel": ["self"], "templated": false] as JSONValue, - ], - "readingOrder": [ - ["href": "chap1.html", "type": "text/html", "templated": false] as JSONValue, - ], - "resources": [ - ["href": "image.png", "type": "image/png", "templated": false] as JSONValue, - ], - "toc": [ - ["href": "cover.html", "templated": false] as JSONValue, - ["href": "chap1.html", "templated": false], - ], - "sub": [ + ).jsonObject == + [ + "@context": ["https://readium.org/webpub-manifest/context.jsonld"], + "metadata": ["title": "Title", "readingProgression": "auto"], "links": [ - ["href": "sublink", "templated": false] as JSONValue, + ["href": "manifest.json", "rel": ["self"], "templated": false] as JSONValue, ], - ], - ] as [String: JSONValue] + "readingOrder": [ + ["href": "chap1.html", "type": "text/html", "templated": false] as JSONValue, + ], + "resources": [ + ["href": "image.png", "type": "image/png", "templated": false] as JSONValue, + ], + "toc": [ + ["href": "cover.html", "templated": false] as JSONValue, + ["href": "chap1.html", "templated": false], + ], + "sub": [ + "links": [ + ["href": "sublink", "templated": false] as JSONValue, + ], + ], + ] as [String: JSONValue] ) } - func testLinkWithRelInReadingOrder() { - XCTAssertEqual( + @Test func linkWithRelInReadingOrder() { + #expect( makeManifest(readingOrder: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).linkWithRel("rel1")?.href, - "l2" + ]).linkWithRel("rel1")?.href == "l2" ) } - func testLinkWithRelInLinks() { - XCTAssertEqual( + @Test func linkWithRelInLinks() { + #expect( makeManifest(links: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).linkWithRel("rel1")?.href, - "l2" + ]).linkWithRel("rel1")?.href == "l2" ) } - func testLinkWithRelInResources() { - XCTAssertEqual( + @Test func linkWithRelInResources() { + #expect( makeManifest(resources: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ]).linkWithRel("rel1")?.href, - "l2" + ]).linkWithRel("rel1")?.href == "l2" ) } - func testLinksWithRel() { - XCTAssertEqual( + @Test func linksWithRel() { + #expect( makeManifest( links: [ Link(href: "l1"), @@ -271,26 +270,92 @@ class ManifestTests: XCTestCase { ]), Link(href: "l6", rel: "rel1"), ] - ).linksWithRel("rel1"), - [ - Link(href: "l4", rel: "rel1"), - Link(href: "l6", rel: "rel1"), - Link(href: "l2", rel: "rel1"), - ] + ).linksWithRel("rel1") == + [ + Link(href: "l4", rel: "rel1"), + Link(href: "l6", rel: "rel1"), + Link(href: "l2", rel: "rel1"), + ] ) } - func testLinksWithRelEmpty() { - XCTAssertEqual( + @Test func linksWithRelEmpty() { + #expect( makeManifest(resources: [ Link(href: "l1"), Link(href: "l2"), - ]).linksWithRel("rel1"), - [] + ]).linksWithRel("rel1") == [] ) } - private func makeManifest(metadata: Metadata = Metadata(title: ""), links: [Link] = [], readingOrder: [Link] = [], resources: [Link] = []) -> Manifest { - Manifest(metadata: metadata, links: links, readingOrder: readingOrder, resources: resources) + struct LocatorForLink { + @Test func minimalLink() { + let sut = makeManifest(readingOrder: [ + Link(href: "/href", mediaType: .html, title: "Resource"), + ]) + + #expect( + sut.locator(for: Link(href: "/href")) == + Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(progression: 0.0)) + ) + } + + @Test func linkInReadingOrderResourcesOrLinks() { + let sut = makeManifest( + links: [Link(href: "/href3", mediaType: .html)], + readingOrder: [Link(href: "/href1", mediaType: .html)], + resources: [Link(href: "/href2", mediaType: .html)] + ) + + #expect( + sut.locator(for: Link(href: "/href1")) == + Locator(href: "/href1", mediaType: .html, locations: Locator.Locations(progression: 0.0)) + ) + #expect( + sut.locator(for: Link(href: "/href2")) == + Locator(href: "/href2", mediaType: .html, locations: Locator.Locations(progression: 0.0)) + ) + #expect( + sut.locator(for: Link(href: "/href3")) == + Locator(href: "/href3", mediaType: .html, locations: Locator.Locations(progression: 0.0)) + ) + } + + @Test func linkWithFragment() throws { + let sut = makeManifest(readingOrder: [ + Link(href: "/href", mediaType: .html, title: "Resource"), + ]) + + #expect( + try sut.locator(for: Link(href: "/href#page=42", mediaType: #require(MediaType("text/xml")), title: "My link")) == + Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(fragments: ["page=42"])) + ) + } + + /// The link's title is used when the resource itself has none. + @Test func fallsBackOnLinkTitle() { + let sut = makeManifest(readingOrder: [ + Link(href: "/href", mediaType: .html), + ]) + + #expect( + sut.locator(for: Link(href: "/href", title: "My link")) == + Locator(href: "/href", mediaType: .html, title: "My link", locations: Locator.Locations(progression: 0.0)) + ) + } + + @Test func unknownLink() { + let sut = makeManifest(readingOrder: [ + Link(href: "/href", mediaType: .html), + ]) + + #expect(sut.locator(for: Link(href: "notfound")) == nil) + } } } + +// MARK: - Helpers + +private func makeManifest(metadata: Metadata = Metadata(title: ""), links: [Link] = [], readingOrder: [Link] = [], resources: [Link] = []) -> Manifest { + Manifest(metadata: metadata, links: links, readingOrder: readingOrder, resources: resources) +} diff --git a/Tests/SharedTests/Publication/PublicationTests.swift b/Tests/SharedTests/Publication/PublicationTests.swift index c7f5546bfc..2f82d050eb 100644 --- a/Tests/SharedTests/Publication/PublicationTests.swift +++ b/Tests/SharedTests/Publication/PublicationTests.swift @@ -5,31 +5,31 @@ // @testable import ReadiumShared -import XCTest +import Testing -class PublicationTests: XCTestCase { - func testGetJSON() { - XCTAssertEqual( +struct PublicationTests { + @Test func getJSON() { + #expect( Publication( manifest: Manifest( metadata: Metadata(title: "Title"), - links: [Link(href: "manifest.json", rels: [.`self`])], + links: [Link(href: "manifest.json", rels: [.self])], readingOrder: [Link(href: "chap1.html", mediaType: .html)] ) - ).jsonManifest, - try? [String: JSONValue]([ - "metadata": ["title": "Title", "readingProgression": "auto"], - "links": [ - ["href": "manifest.json", "rel": ["self"], "templated": false], - ], - "readingOrder": [ - ["href": "chap1.html", "type": "text/html", "templated": false], - ], - ]).jsonString() + ).jsonManifest == + (try? [String: JSONValue]([ + "metadata": ["title": "Title", "readingProgression": "auto"], + "links": [ + ["href": "manifest.json", "rel": ["self"], "templated": false], + ], + "readingOrder": [ + ["href": "chap1.html", "type": "text/html", "templated": false], + ], + ]).jsonString()) ) } - func testConformsToProfile() { + @Test func conformsToProfile() { func makePub(_ readingOrder: [Link], conformsTo: [Publication.Profile] = []) -> Publication { Publication(manifest: Manifest( metadata: Metadata(conformsTo: conformsTo), @@ -38,291 +38,307 @@ class PublicationTests: XCTestCase { } // An empty reading order doesn't conform to anything. - XCTAssertFalse(makePub([], conformsTo: [.epub]).conforms(to: .epub)) + #expect(!makePub([], conformsTo: [.epub]).conforms(to: .epub)) - XCTAssertTrue(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)]).conforms(to: .audiobook)) - XCTAssertTrue(makePub([Link(href: "c1.jpg", mediaType: .jpeg), Link(href: "c2.png", mediaType: .png)]).conforms(to: .divina)) - XCTAssertTrue(makePub([Link(href: "c1.pdf", mediaType: .pdf), Link(href: "c2.pdf", mediaType: .pdf)]).conforms(to: .pdf)) + #expect(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)]).conforms(to: .audiobook)) + #expect(makePub([Link(href: "c1.jpg", mediaType: .jpeg), Link(href: "c2.png", mediaType: .png)]).conforms(to: .divina)) + #expect(makePub([Link(href: "c1.pdf", mediaType: .pdf), Link(href: "c2.pdf", mediaType: .pdf)]).conforms(to: .pdf)) // Mixed media types disable implicit conformance. - XCTAssertFalse(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.jpg", mediaType: .jpeg)]).conforms(to: .audiobook)) - XCTAssertFalse(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.jpg", mediaType: .jpeg)]).conforms(to: .divina)) + #expect(!makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.jpg", mediaType: .jpeg)]).conforms(to: .audiobook)) + #expect(!makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.jpg", mediaType: .jpeg)]).conforms(to: .divina)) // XHTML could be EPUB or a Web Publication, so we require an explicit EPUB profile. - XCTAssertFalse(makePub([Link(href: "c1.xhtml", mediaType: .xhtml), Link(href: "c2.xhtml", mediaType: .xhtml)]).conforms(to: .epub)) - XCTAssertFalse(makePub([Link(href: "c1.html", mediaType: .html), Link(href: "c2.html", mediaType: .html)]).conforms(to: .epub)) - XCTAssertTrue(makePub([Link(href: "c1.xhtml", mediaType: .xhtml), Link(href: "c2.xhtml", mediaType: .xhtml)], conformsTo: [.epub]).conforms(to: .epub)) - XCTAssertTrue(makePub([Link(href: "c1.html", mediaType: .html), Link(href: "c2.html", mediaType: .html)], conformsTo: [.epub]).conforms(to: .epub)) + #expect(!makePub([Link(href: "c1.xhtml", mediaType: .xhtml), Link(href: "c2.xhtml", mediaType: .xhtml)]).conforms(to: .epub)) + #expect(!makePub([Link(href: "c1.html", mediaType: .html), Link(href: "c2.html", mediaType: .html)]).conforms(to: .epub)) + #expect(makePub([Link(href: "c1.xhtml", mediaType: .xhtml), Link(href: "c2.xhtml", mediaType: .xhtml)], conformsTo: [.epub]).conforms(to: .epub)) + #expect(makePub([Link(href: "c1.html", mediaType: .html), Link(href: "c2.html", mediaType: .html)], conformsTo: [.epub]).conforms(to: .epub)) // Implicit conformance always take precedence over explicit profiles. - XCTAssertTrue(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)]).conforms(to: .audiobook)) - XCTAssertTrue(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)], conformsTo: [.divina]).conforms(to: .audiobook)) - XCTAssertFalse(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)], conformsTo: [.divina]).conforms(to: .divina)) + #expect(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)]).conforms(to: .audiobook)) + #expect(makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)], conformsTo: [.divina]).conforms(to: .audiobook)) + #expect(!makePub([Link(href: "c1.mp3", mediaType: .mp3), Link(href: "c2.aac", mediaType: .aac)], conformsTo: [.divina]).conforms(to: .divina)) // Unknown profile let profile = Publication.Profile("http://extension") - XCTAssertFalse(makePub([Link(href: "file", mediaType: .text)]).conforms(to: profile)) - XCTAssertTrue(makePub([Link(href: "file", mediaType: .text)], conformsTo: [profile]).conforms(to: profile)) + #expect(!makePub([Link(href: "file", mediaType: .text)]).conforms(to: profile)) + #expect(makePub([Link(href: "file", mediaType: .text)], conformsTo: [profile]).conforms(to: profile)) } - func testBaseURL() { - XCTAssertEqual( - makePublication(links: [ - Link(href: "http://host/folder/manifest.json", rel: .`self`), - ]).baseURL?.string, - "http://host/folder/" + /// `Publication.get()` delegates to the `Container`. + @Test func getDelegatesToContainer() async throws { + let link = Link(href: "test", mediaType: .html) + let publication = makePublication( + links: [link], + container: SingleResourceContainer(resource: DataResource(string: "hello"), at: link.url()) ) - } - func testBaseURLMissing() { - XCTAssertNil( - makePublication(links: [ - Link(href: "http://host/folder/manifest.json"), - ]).baseURL - ) + let result = try await publication.get(link)?.read().asString().get() + #expect(result == "hello") } - func testBaseURLRoot() { - XCTAssertEqual( - makePublication(links: [ - Link(href: "http://host/manifest.json", rel: .`self`), - ]).baseURL?.string, - "http://host/" - ) - } + struct BaseURL { + @Test func fromSelfLink() { + #expect( + makePublication(links: [ + Link(href: "http://host/folder/manifest.json", rel: .self), + ]).baseURL?.string == "http://host/folder/" + ) + } - func testLinkWithHREFInReadingOrder() throws { - XCTAssertEqual( - try makePublication(readingOrder: [ - Link(href: "l1"), - Link(href: "l2"), - ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, - "l2" - ) - } + @Test func missingWithoutSelfLink() { + #expect( + makePublication(links: [ + Link(href: "http://host/folder/manifest.json"), + ]).baseURL == nil + ) + } - func testLinkWithHREFInLinks() throws { - XCTAssertEqual( - try makePublication(links: [ - Link(href: "l1"), - Link(href: "l2"), - ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, - "l2" - ) + @Test func atRoot() { + #expect( + makePublication(links: [ + Link(href: "http://host/manifest.json", rel: .self), + ]).baseURL?.string == "http://host/" + ) + } } - func testLinkWithHREFInResources() throws { - XCTAssertEqual( - try makePublication(resources: [ - Link(href: "l1"), - Link(href: "l2"), - ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l2")))?.href, - "l2" - ) - } + struct LinkWithHREF { + @Test func inReadingOrder() { + #expect( + makePublication(readingOrder: [ + Link(href: "l1"), + Link(href: "l2"), + ]).linkWithHREF(AnyURL(string: "l2")!)?.href == "l2" + ) + } - func testLinkWithHREFInAlternate() throws { - XCTAssertEqual( - try makePublication(resources: [ - Link(href: "l1", alternates: [ - Link(href: "l2", alternates: [ - Link(href: "l3"), - ]), - ]), - ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l3")))?.href, - "l3" - ) - } + @Test func inLinks() { + #expect( + makePublication(links: [ + Link(href: "l1"), + Link(href: "l2"), + ]).linkWithHREF(AnyURL(string: "l2")!)?.href == "l2" + ) + } - func testLinkWithHREFInChildren() throws { - XCTAssertEqual( - try makePublication(resources: [ - Link(href: "l1", children: [ - Link(href: "l2", children: [ - Link(href: "l3"), + @Test func inResources() { + #expect( + makePublication(resources: [ + Link(href: "l1"), + Link(href: "l2"), + ]).linkWithHREF(AnyURL(string: "l2")!)?.href == "l2" + ) + } + + @Test func inAlternate() { + #expect( + makePublication(resources: [ + Link(href: "l1", alternates: [ + Link(href: "l2", alternates: [ + Link(href: "l3"), + ]), ]), - ]), - ]).linkWithHREF(XCTUnwrap(AnyURL(string: "l3")))?.href, - "l3" - ) - } + ]).linkWithHREF(AnyURL(string: "l3")!)?.href == "l3" + ) + } - func testLinkWithHREFIgnoresQuery() throws { - let publication = makePublication(links: [ - Link(href: "l1?q=a"), - Link(href: "l2"), - ]) + @Test func inChildren() { + #expect( + makePublication(resources: [ + Link(href: "l1", children: [ + Link(href: "l2", children: [ + Link(href: "l3"), + ]), + ]), + ]).linkWithHREF(AnyURL(string: "l3")!)?.href == "l3" + ) + } - XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l1?q=a")))?.href, "l1?q=a") - XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l2?q=b")))?.href, "l2") - } + @Test func ignoresQuery() { + let publication = makePublication(links: [ + Link(href: "l1?q=a"), + Link(href: "l2"), + ]) - func testLinkWithHREFIgnoresAnchor() throws { - let publication = makePublication(links: [ - Link(href: "l1#a"), - Link(href: "l2"), - ]) + #expect(publication.linkWithHREF(AnyURL(string: "l1?q=a")!)?.href == "l1?q=a") + #expect(publication.linkWithHREF(AnyURL(string: "l2?q=b")!)?.href == "l2") + } - XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l1#a")))?.href, "l1#a") - XCTAssertEqual(try publication.linkWithHREF(XCTUnwrap(AnyURL(string: "l2#b")))?.href, "l2") - } + @Test func ignoresAnchor() { + let publication = makePublication(links: [ + Link(href: "l1#a"), + Link(href: "l2"), + ]) - func testLinkWithRelInReadingOrder() { - XCTAssertEqual( - makePublication(readingOrder: [ - Link(href: "l1"), - Link(href: "l2", rel: "rel1"), - ]).linkWithRel("rel1")?.href, - "l2" - ) + #expect(publication.linkWithHREF(AnyURL(string: "l1#a")!)?.href == "l1#a") + #expect(publication.linkWithHREF(AnyURL(string: "l2#b")!)?.href == "l2") + } } - func testLinkWithRelInLinks() { - XCTAssertEqual( - makePublication(links: [ - Link(href: "l1"), - Link(href: "l2", rel: "rel1"), - ]).linkWithRel("rel1")?.href, - "l2" - ) - } + struct LinkWithRel { + @Test func inReadingOrder() { + #expect( + makePublication(readingOrder: [ + Link(href: "l1"), + Link(href: "l2", rel: "rel1"), + ]).linkWithRel("rel1")?.href == "l2" + ) + } - func testLinkWithRelInResources() { - XCTAssertEqual( - makePublication(resources: [ - Link(href: "l1"), - Link(href: "l2", rel: "rel1"), - ]).linkWithRel("rel1")?.href, - "l2" - ) - } + @Test func inLinks() { + #expect( + makePublication(links: [ + Link(href: "l1"), + Link(href: "l2", rel: "rel1"), + ]).linkWithRel("rel1")?.href == "l2" + ) + } - func testLinksWithRel() { - XCTAssertEqual( - makePublication( - links: [ + @Test func inResources() { + #expect( + makePublication(resources: [ Link(href: "l1"), Link(href: "l2", rel: "rel1"), - ], - readingOrder: [ - Link(href: "l3"), - Link(href: "l4", rel: "rel1"), - ], - resources: [ - Link(href: "l5", alternates: [ - Link(href: "alternate", rel: "rel1"), - ]), - Link(href: "l6", rel: "rel1"), - ] - ).linksWithRel("rel1"), - [ - Link(href: "l4", rel: "rel1"), - Link(href: "l6", rel: "rel1"), - Link(href: "l2", rel: "rel1"), - ] - ) - } + ]).linkWithRel("rel1")?.href == "l2" + ) + } - func testLinksWithRelEmpty() { - XCTAssertEqual( - makePublication(resources: [ - Link(href: "l1"), - Link(href: "l2"), - ]).linksWithRel("rel1"), - [] - ) + @Test func allMatchingLinks() { + #expect( + makePublication( + links: [ + Link(href: "l1"), + Link(href: "l2", rel: "rel1"), + ], + readingOrder: [ + Link(href: "l3"), + Link(href: "l4", rel: "rel1"), + ], + resources: [ + Link(href: "l5", alternates: [ + Link(href: "alternate", rel: "rel1"), + ]), + Link(href: "l6", rel: "rel1"), + ] + ).linksWithRel("rel1") == + [ + Link(href: "l4", rel: "rel1"), + Link(href: "l6", rel: "rel1"), + Link(href: "l2", rel: "rel1"), + ] + ) + } + + @Test func noMatchingLinks() { + #expect( + makePublication(resources: [ + Link(href: "l1"), + Link(href: "l2"), + ]).linksWithRel("rel1") == [] + ) + } } - /// `Publication.get()` delegates to the `Container`. - func testGetDelegatesToContainer() async throws { - let link = Link(href: "test", mediaType: .html) - let publication = makePublication( - links: [link], - container: SingleResourceContainer(resource: DataResource(string: "hello"), at: link.url()) - ) + struct LocatorForLink { + /// `Publication` forwards to its manifest. + @Test func forwardsToManifest() { + let sut = makePublication(readingOrder: [ + Link(href: "/href", mediaType: .html, title: "Resource"), + ]) - let result = try await publication.get(link)?.read().asString().get() - XCTAssertEqual(result, "hello") - } + #expect( + sut.locator(for: Link(href: "/href")) == + Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(progression: 0.0)) + ) + } - private func makePublication( - metadata: Metadata = Metadata(title: ""), - links: [Link] = [], - readingOrder: [Link] = [], - resources: [Link] = [], - container: Container? = nil, - services: PublicationServicesBuilder = PublicationServicesBuilder() - ) -> Publication { - Publication( - manifest: Manifest( - metadata: metadata, - links: links, - readingOrder: readingOrder, - resources: resources - ), - container: container ?? EmptyContainer(), - servicesBuilder: services - ) + @Test func unknownLink() { + let sut = makePublication(readingOrder: [ + Link(href: "/href", mediaType: .html), + ]) + + #expect(sut.locator(for: Link(href: "notfound")) == nil) + } } - func testNormalizeLocatorRemotePublication() { - let publication = Publication( - manifest: Manifest( - links: [Link(href: "https://example.com/foo/manifest.json", rels: [.`self`])], - readingOrder: [ - Link(href: "chap1.html", mediaType: .html), - Link(href: "bar/c'est%20valide.html", mediaType: .html), - ] + struct NormalizeLocator { + @Test func remotePublication() { + let publication = Publication( + manifest: Manifest( + links: [Link(href: "https://example.com/foo/manifest.json", rels: [.self])], + readingOrder: [ + Link(href: "chap1.html", mediaType: .html), + Link(href: "bar/c'est%20valide.html", mediaType: .html), + ] + ) ) - ) - // Passthrough for invalid locators. - XCTAssertEqual( - publication.normalizeLocator( - Locator(href: "invalid", mediaType: .html) - ), - Locator(href: "invalid", mediaType: .html) - ) + // Passthrough for invalid locators. + #expect( + publication.normalizeLocator( + Locator(href: "invalid", mediaType: .html) + ) == Locator(href: "invalid", mediaType: .html) + ) - // Absolute URLs relative to self are made relative. - XCTAssertEqual( - publication.normalizeLocator( - Locator(href: "https://example.com/foo/chap1.html", mediaType: .html) - ), - Locator(href: "chap1.html", mediaType: .html) - ) - XCTAssertEqual( - publication.normalizeLocator( - Locator(href: "https://other.com/chap1.html", mediaType: .html) - ), - Locator(href: "https://other.com/chap1.html", mediaType: .html) - ) - } + // Absolute URLs relative to self are made relative. + #expect( + publication.normalizeLocator( + Locator(href: "https://example.com/foo/chap1.html", mediaType: .html) + ) == Locator(href: "chap1.html", mediaType: .html) + ) + #expect( + publication.normalizeLocator( + Locator(href: "https://other.com/chap1.html", mediaType: .html) + ) == Locator(href: "https://other.com/chap1.html", mediaType: .html) + ) + } - func testNormalizeLocatorPackagedPublication() { - let publication = Publication( - manifest: Manifest( - readingOrder: [ - Link(href: "foo/chap1.html", mediaType: .html), - Link(href: "bar/c'est%20valide.html", mediaType: .html), - ] + @Test func packagedPublication() { + let publication = Publication( + manifest: Manifest( + readingOrder: [ + Link(href: "foo/chap1.html", mediaType: .html), + Link(href: "bar/c'est%20valide.html", mediaType: .html), + ] + ) ) - ) - // Passthrough for invalid locators. - XCTAssertEqual( - publication.normalizeLocator( - Locator(href: "invalid", mediaType: .html) - ), - Locator(href: "invalid", mediaType: .html) - ) + // Passthrough for invalid locators. + #expect( + publication.normalizeLocator( + Locator(href: "invalid", mediaType: .html) + ) == Locator(href: "invalid", mediaType: .html) + ) - // Leading slashes are removed - XCTAssertEqual( - publication.normalizeLocator( - Locator(href: "foo/chap1.html", mediaType: .html) - ), - Locator(href: "foo/chap1.html", mediaType: .html) - ) + // Leading slashes are removed + #expect( + publication.normalizeLocator( + Locator(href: "foo/chap1.html", mediaType: .html) + ) == Locator(href: "foo/chap1.html", mediaType: .html) + ) + } } } + +// MARK: - Helpers + +private func makePublication( + metadata: Metadata = Metadata(title: ""), + links: [Link] = [], + readingOrder: [Link] = [], + resources: [Link] = [], + container: Container? = nil, + services: PublicationServicesBuilder = PublicationServicesBuilder() +) -> Publication { + Publication( + manifest: Manifest( + metadata: metadata, + links: links, + readingOrder: readingOrder, + resources: resources + ), + container: container ?? EmptyContainer(), + servicesBuilder: services + ) +} diff --git a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift index d12e0a70ba..78c9a7a4b2 100644 --- a/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift +++ b/Tests/SharedTests/Publication/Services/Locator/DefaultLocatorServiceTests.swift @@ -126,77 +126,6 @@ class DefaultLocatorServiceTests: XCTestCase { XCTAssertNil(result) } - func testFromMinimalLink() async { - let sut = makeService(readingOrder: [ - Link(href: "/href", mediaType: .html, title: "Resource"), - ]) - - let result = await sut.service.locate(Link(href: "/href")) - XCTAssertEqual( - result, - Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(progression: 0.0)) - ) - } - - func testFromLinkInReadingOrderResourcesOrLinks() async { - let sut = makeService( - links: [Link(href: "/href3", mediaType: .html)], - readingOrder: [Link(href: "/href1", mediaType: .html)], - resources: [Link(href: "/href2", mediaType: .html)] - ) - - var result = await sut.service.locate(Link(href: "/href1")) - XCTAssertEqual( - result, - Locator(href: "/href1", mediaType: .html, locations: Locator.Locations(progression: 0.0)) - ) - - result = await sut.service.locate(Link(href: "/href2")) - XCTAssertEqual( - result, - Locator(href: "/href2", mediaType: .html, locations: Locator.Locations(progression: 0.0)) - ) - - result = await sut.service.locate(Link(href: "/href3")) - XCTAssertEqual( - result, - Locator(href: "/href3", mediaType: .html, locations: Locator.Locations(progression: 0.0)) - ) - } - - func testFromLinkWithFragment() async throws { - let sut = makeService(readingOrder: [ - Link(href: "/href", mediaType: .html, title: "Resource"), - ]) - - let result = try await sut.service.locate(Link(href: "/href#page=42", mediaType: XCTUnwrap(MediaType("text/xml")), title: "My link")) - XCTAssertEqual( - result, - Locator(href: "/href", mediaType: .html, title: "Resource", locations: Locator.Locations(fragments: ["page=42"])) - ) - } - - func testTitleFallbackFromLink() async { - let sut = makeService(readingOrder: [ - Link(href: "/href", mediaType: .html), - ]) - - let result = await sut.service.locate(Link(href: "/href", title: "My link")) - XCTAssertEqual( - result, - Locator(href: "/href", mediaType: .html, title: "My link", locations: Locator.Locations(progression: 0.0)) - ) - } - - func testFromLinkNotFound() async { - let sut = makeService(readingOrder: [ - Link(href: "/href", mediaType: .html), - ]) - - let result = await sut.service.locate(Link(href: "notfound")) - XCTAssertNil(result) - } - struct Context { var publication: Publication var service: DefaultLocatorService