Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .forgeos/intent/pp-4542-audiobook-position-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
name: PP-4542 audiobook position-validation candidate-selection coverage
created: 2026-06-08
author: claude-opus-4-8
---

## Summary

F-007 (PP-4542): the position-validation / candidate-selection logic added to
`AudiobookSessionManager` by PR #1028 (stale-position-on-reborrow hardening)
shipped with a 0% diff-only mutation kill rate on the changed lines. Add
behavior tests at the production seams that pin the real decisions —
candidate recency ordering, TOC track-key match, the `validationFailure == nil`
filter, `isValidPosition`, and the `isUserAuthenticated` auth-doc-load-failure
branch — with constructed TOC + registry + account fixtures (no live
Audiobook/player graph).

## Claims

- adds test class `PalaceTests/Audiobooks/AudiobookPositionRestoreTests.swift`
with behavior tests for candidate ordering, TOC track-key match,
validation-filter drop, isValidPosition, and isUserAuthenticated failure
- extracts a pure `selectMostRecentValidBookmark(from:in:)` seam out of
`fallbackToMostRecentValidBookmark` so the candidate filter+sort is testable
against an `AudiobookTableOfContents` without a live `Audiobook`
- changes visibility of `getValidLocalPosition`, `fallbackToMostRecentValidBookmark`,
`validationFailure(for:in:)`, `isValidPosition`, and `isUserAuthenticated`
from `private` to `internal` so `@testable` tests reach them
- registers the new test file in `Palace.xcodeproj` (PalaceTests target)

## Anti-claims

- does NOT change any shipping behavior — the extraction is a pure refactor
(same candidates, same filter, same sort) and the visibility changes are
test-reachability only
- does NOT change any call site, method signature semantics, or control flow
on the audiobook open/restore path
- does NOT touch the audiobook toolkit submodule or any DRM/network code

## Files in scope

- Palace/Audiobooks/AudiobookSessionManager.swift
- PalaceTests/Audiobooks/AudiobookPositionRestoreTests.swift (NEW)
- Palace.xcodeproj/project.pbxproj
- .forgeos/intent/pp-4542-audiobook-position-coverage.md (NEW)
4 changes: 4 additions & 0 deletions Palace.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
10DCC70174134074821BB5E3 /* TPPBookmarkR3LocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FA6A7940BE4470BD0A3515 /* TPPBookmarkR3LocationTests.swift */; };
110F853E19D5FA7300052DF7 /* DetailSummaryTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = 110F853C19D5FA7300052DF7 /* DetailSummaryTemplate.html */; };
1112A8431A3249B4002B8CC1 /* libTenPrintCover.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1112A81F1A322C53002B8CC1 /* libTenPrintCover.a */; };
11251173E2E3C47D3E927FB2 /* AudiobookPositionRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7409BB4778BF1736365594FB /* AudiobookPositionRestoreTests.swift */; };
119503E71993F914009FB788 /* libxml2.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 119503E61993F914009FB788 /* libxml2.dylib */; };
119503E91993F919009FB788 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 119503E81993F919009FB788 /* libz.dylib */; };
121E8B66B8634CD720E7C524 /* CatalogRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA39820CBA75D6BA9B763E2 /* CatalogRepositoryTests.swift */; };
Expand Down Expand Up @@ -2501,6 +2502,7 @@
73F713552417200F00C63B81 /* TPPBaseReaderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TPPBaseReaderViewController.swift; path = Palace/Reader2/UI/TPPBaseReaderViewController.swift; sourceTree = SOURCE_ROOT; };
73F713672417240100C63B81 /* UIViewController+TPP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIViewController+TPP.swift"; path = "Palace/Reader2/UI/UIViewController+TPP.swift"; sourceTree = SOURCE_ROOT; };
73FB0AC824EB403D0072E430 /* TPPBookContentTypeConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TPPBookContentTypeConverter.swift; sourceTree = "<group>"; };
7409BB4778BF1736365594FB /* AudiobookPositionRestoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookPositionRestoreTests.swift; sourceTree = "<group>"; };
74D09C16D15567198A0A9194 /* StatsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsViewModelTests.swift; sourceTree = "<group>"; };
75305888F6A941DDA9EEE592 /* RDServicesStubs.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RDServicesStubs.m; sourceTree = "<group>"; };
758B05F321F1562FFC733397 /* AudiobookPositionAdapterContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookPositionAdapterContractTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4661,6 +4663,7 @@
B3CBF74C265D4FA1483F1974 /* AudiobookSessionManagerPresenterMigrationTests.swift */,
787DCAF97FD5E190363EC3CA /* AudiobookSessionManagerFlagGatePresentationTests.swift */,
8D899B519469E743D48FFACC /* AudiobookPlaytimesLifecycleTests.swift */,
7409BB4778BF1736365594FB /* AudiobookPositionRestoreTests.swift */,
);
path = Audiobooks;
sourceTree = "<group>";
Expand Down Expand Up @@ -7354,6 +7357,7 @@
32910EA3300D7728B6FF2268 /* ScopedResetTests.swift in Sources */,
151C578EEB3CBF8B804952F6 /* DeveloperSettingsTierTests.swift in Sources */,
6D575E8878F3E8BC9FA43DE3 /* ImageCacheContinuationTests.swift in Sources */,
11251173E2E3C47D3E927FB2 /* AudiobookPositionRestoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
43 changes: 35 additions & 8 deletions Palace/Audiobooks/AudiobookSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1204,7 +1204,12 @@ public final class AudiobookSessionManager: ObservableObject {
/// generic bookmarks for this book, the most-recent valid one is returned
/// as a fallback (better than dropping the patron to chapter-1 start).
/// Returns `nil` only when there's nothing usable at all.
private func getValidLocalPosition(book: TPPBook, audiobook: Audiobook) -> TrackPosition? {
// `internal` (not `private`) so `@testable import Palace` unit tests can
// drive the position-restore decision through this production seam with
// constructed registry/TOC fixtures — see AudiobookPositionRestoreTests.
// This is the same seam-exposure pattern already used by the static
// policy mirrors (networkValidationError, normalizedChaptersCount).
func getValidLocalPosition(book: TPPBook, audiobook: Audiobook) -> TrackPosition? {
let primary = tryLoadPrimaryLocalPosition(book: book, audiobook: audiobook)
switch primary {
case .success(let position):
Expand Down Expand Up @@ -1274,22 +1279,41 @@ public final class AudiobookSessionManager: ObservableObject {
/// the validator accepts it AND it parses against the current manifest.
/// Recency is by `lastSavedTimeStamp` (ISO8601), falling back to array
/// order when timestamps are missing.
private func fallbackToMostRecentValidBookmark(
func fallbackToMostRecentValidBookmark(
book: TPPBook,
audiobook: Audiobook
) -> TrackPosition? {
let bookmarks = bookRegistry.genericBookmarksForIdentifier(book.identifier)
guard !bookmarks.isEmpty else { return nil }
return selectMostRecentValidBookmark(
from: bookmarks,
in: audiobook.tableOfContents
)
}

/// Pure candidate-selection seam: from a set of saved generic bookmarks,
/// reconstruct each against the manifest, drop any that fail validation,
/// and return the most-recent valid one (descending `lastSavedTimeStamp`,
/// which is ISO8601 and therefore lexicographically sortable).
///
/// `internal` (not `private`) and threaded `AudiobookTableOfContents`
/// instead of the full `Audiobook` so the validation filter and the
/// recency ordering are mutation-testable from a unit test with a real
/// TOC + seeded `TPPBookLocation` fixtures — no live `Audiobook` /
/// player graph required. Mirrors the `validationFailure(for:in:)` seam.
func selectMostRecentValidBookmark(
from bookmarks: [TPPBookLocation],
in tableOfContents: AudiobookTableOfContents
) -> TrackPosition? {
let candidates: [(TrackPosition, String)] = bookmarks.compactMap { location in
guard let dict = location.locationStringDictionary(),
let bookmark = AudioBookmark.create(locatorData: dict),
let position = TrackPosition(
audioBookmark: bookmark,
toc: audiobook.tableOfContents.toc,
tracks: audiobook.tableOfContents.tracks
toc: tableOfContents.toc,
tracks: tableOfContents.tracks
),
validationFailure(for: position, in: audiobook.tableOfContents) == nil else {
validationFailure(for: position, in: tableOfContents) == nil else {
return nil
}
return (position, bookmark.lastSavedTimeStamp ?? "")
Expand All @@ -1304,7 +1328,7 @@ public final class AudiobookSessionManager: ObservableObject {
/// Re-uses `AudiobookPositionPolicy.validate`. The thin shim adapts the
/// instance-level call site (which already has the toolkit's position
/// object) to the pure-function policy (which doesn't need the toolkit).
private func validationFailure(
func validationFailure(
for position: TrackPosition,
in tableOfContents: AudiobookTableOfContents
) -> AudiobookPositionValidationFailure? {
Expand Down Expand Up @@ -1339,7 +1363,7 @@ public final class AudiobookSessionManager: ObservableObject {
/// Kept as a thin wrapper for any in-file callers that just want a bool.
/// New code should use `validationFailure(for:in:)` directly so the
/// failure mode can be logged.
private func isValidPosition(_ position: TrackPosition, in tableOfContents: AudiobookTableOfContents) -> Bool {
func isValidPosition(_ position: TrackPosition, in tableOfContents: AudiobookTableOfContents) -> Bool {
return validationFailure(for: position, in: tableOfContents) == nil
}

Expand Down Expand Up @@ -1499,7 +1523,10 @@ public final class AudiobookSessionManager: ObservableObject {
/// 20s session-manager timeout is the sole timeout on this path — per
/// the ADR's single-timeout policy we do NOT wrap awaitReady() in
/// withTimeout here.
private func isUserAuthenticated() async -> Bool {
// `internal` (not `private`) so `@testable` tests can pin the
// auth-doc-load-failure → not-authenticated mapping (the `catch`
// branch below) through this seam — see AudiobookPositionRestoreTests.
func isUserAuthenticated() async -> Bool {
guard let account = accountsManager.currentAccount else {
return false
}
Expand Down
Loading
Loading