Skip to content
Closed
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
6 changes: 5 additions & 1 deletion projects/ios-readplace/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ open: generate
# someone builds `make ipa-staging` by hand.
SIM ?= platform=iOS Simulator,name=$(shell xcrun simctl list devices available | grep -m1 -oE 'iPhone [0-9]+')
SIM_DEVICE = $(shell printf '%s' '$(SIM)' | sed -E 's/.*=//')
COVERAGE_RESULT = build/TestResults.xcresult
test: generate
xcrun simctl bootstatus '$(SIM_DEVICE)' -b
xcodebuild test -project Readplace.xcodeproj -scheme Readplace -destination '$(SIM)'
rm -rf $(COVERAGE_RESULT)
xcodebuild test -project Readplace.xcodeproj -scheme Readplace -destination '$(SIM)' \
-enableCodeCoverage YES -resultBundlePath $(COVERAGE_RESULT)
python3 scripts/check-coverage.py $(COVERAGE_RESULT)
$(MAKE) test-staging SIM='$(SIM)'

# Compile the STAGING condition and smoke-test it. The full suite can't run under
Expand Down
8 changes: 6 additions & 2 deletions projects/ios-readplace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,12 @@ You need a Mac with **Xcode 15+** and an Apple ID (a free personal team is fine)

`make test` (or `Cmd+U` in Xcode) runs the XCTest suite. The network is stubbed
with a `URLProtocol`, so tests exercise the real client logic — headers, bodies,
redirects, retries — without a server. Coverage focuses on boundaries and edge
cases:
redirects, retries — without a server. `make test` then enforces a per-file
line-coverage gate (`scripts/check-coverage.py` against
`scripts/coverage-baseline.json`): pure SwiftUI views and WebKit/OS-boundary glue
are excluded, and every other source file must stay at or above its recorded
floor — a ratchet the logic files are walked toward 100%. Coverage focuses on
boundaries and edge cases:

- **Siren decoding**: rich vs. minimal entities, JSON `null` image/`readAt`,
read-state from `status`/`readAt`, title fallback to URL, entities without
Expand Down
28 changes: 10 additions & 18 deletions projects/ios-readplace/ShareExtension/ShareViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,16 @@ final class ShareViewController: UIViewController {
let sharedPdf: (() async -> Data?)? = shared?.pdfProvider.map { provider in
{ await ShareURLExtractor.loadPDFData(provider) }
}
switch await saver.run(url: shared?.url, fallbackTitle: shared?.title, sharedPdf: sharedPdf) {
case .savedWithContent:
finish(message: "Saved with content", symbol: "checkmark.circle.fill", tint: .brandSuccess)
case .savedLinkOnly:
finish(message: "Saved (link only)", symbol: "checkmark.circle.fill", tint: .brandSuccess)
case .notLoggedIn:
finish(message: "Open Readplace and sign in first.",
symbol: "person.crop.circle.badge.exclamationmark", tint: .brandWarning)
case .noLink:
finish(message: "No link found to save.", symbol: "link", tint: .brandWarning)
case .noSaveAction:
finish(message: "The server offered no save action.",
symbol: "exclamationmark.triangle.fill", tint: .brandError)
case .refused(let messages):
finish(message: messages.map(\.plainText).joined(separator: "\n"), symbol: "lock.fill",
tint: messages.contains { $0.kind == .error } ? .brandError : .brandWarning)
case .failed(let message):
finish(message: message, symbol: "exclamationmark.triangle.fill", tint: .brandError)
let outcome = await saver.run(url: shared?.url, fallbackTitle: shared?.title, sharedPdf: sharedPdf)
let status = ShareStatusPresentation(outcome: outcome)
finish(message: status.message, symbol: status.symbol, tint: uiColor(for: status.tone))
}

private func uiColor(for tone: ShareStatusTone) -> UIColor {
switch tone {
case .success: return .brandSuccess
case .warning: return .brandWarning
case .error: return .brandError
}
}

Expand Down
54 changes: 54 additions & 0 deletions projects/ios-readplace/Shared/ShareStatusPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// The visual tone of a share-sheet status, mapped to a brand colour at the
/// UIKit boundary. Kept UIKit-free so the outcome→status mapping stays a pure,
/// testable value.
enum ShareStatusTone: Equatable {
case success
case warning
case error
}

/// What the share-sheet shell shows for a save outcome: the message text, an SF
/// Symbol name, and a tone. Lifted out of `ShareViewController` so the whole
/// mapping — including joining a refusal's messages and choosing error vs.
/// warning from their kinds — is a pure value the tests exercise directly; the
/// controller only paints it and maps the tone to a `UIColor`.
struct ShareStatusPresentation: Equatable {
let message: String
let symbol: String
let tone: ShareStatusTone

init(outcome: SaveSharedOutcome) {
switch outcome {
case .savedWithContent:
message = "Saved with content"
symbol = "checkmark.circle.fill"
tone = .success
case .savedLinkOnly:
message = "Saved (link only)"
symbol = "checkmark.circle.fill"
tone = .success
case .notLoggedIn:
message = "Open Readplace and sign in first."
symbol = "person.crop.circle.badge.exclamationmark"
tone = .warning
case .noLink:
message = "No link found to save."
symbol = "link"
tone = .warning
case .noSaveAction:
message = "The server offered no save action."
symbol = "exclamationmark.triangle.fill"
tone = .error
case .refused(let messages):
message = messages.map(\.plainText).joined(separator: "\n")
symbol = "lock.fill"
tone = messages.contains { $0.kind == .error } ? .error : .warning
case .failed(let failureMessage):
message = failureMessage
symbol = "exclamationmark.triangle.fill"
tone = .error
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ enum ShareURLExtractor {

static func extract(from context: NSExtensionContext?) async -> Shared? {
guard let items = context?.inputItems as? [NSExtensionItem] else { return nil }
return await extract(from: items)
}

/// The extraction core, taking the item list directly so a test can drive it
/// with fabricated `NSItemProvider`s instead of standing up a live extension
/// context (which no test can construct).
static func extract(from items: [NSExtensionItem]) async -> Shared? {
// Scan every item before deciding: a host can deliver the PDF file and
// the web URL as separate extension items, and the first item alone
// would look like a URL-less share.
Expand Down
64 changes: 64 additions & 0 deletions projects/ios-readplace/Tests/ShareStatusPresentationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import XCTest
@testable import Readplace

final class ShareStatusPresentationTests: XCTestCase {
private func present(_ outcome: SaveSharedOutcome) -> ShareStatusPresentation {
ShareStatusPresentation(outcome: outcome)
}

private func message(type: String, body: String = "x") -> ServerMessage {
ServerMessage(type: type, content: ServerMessage.Content(type: "text/html", body: body))
}

func testSavedWithContentIsSuccess() {
let status = present(.savedWithContent)
XCTAssertEqual(status.message, "Saved with content")
XCTAssertEqual(status.symbol, "checkmark.circle.fill")
XCTAssertEqual(status.tone, .success)
}

func testSavedLinkOnlyIsSuccess() {
let status = present(.savedLinkOnly)
XCTAssertEqual(status.message, "Saved (link only)")
XCTAssertEqual(status.tone, .success)
}

func testNotLoggedInIsWarning() {
let status = present(.notLoggedIn)
XCTAssertEqual(status.message, "Open Readplace and sign in first.")
XCTAssertEqual(status.symbol, "person.crop.circle.badge.exclamationmark")
XCTAssertEqual(status.tone, .warning)
}

func testNoLinkIsWarning() {
let status = present(.noLink)
XCTAssertEqual(status.message, "No link found to save.")
XCTAssertEqual(status.symbol, "link")
XCTAssertEqual(status.tone, .warning)
}

func testNoSaveActionIsError() {
let status = present(.noSaveAction)
XCTAssertEqual(status.message, "The server offered no save action.")
XCTAssertEqual(status.tone, .error)
}

func testRefusedJoinsMessagesAndIsWarningWhenNoneAreErrors() {
let status = present(.refused([message(type: "warning", body: "one"), message(type: "warning", body: "two")]))
XCTAssertEqual(status.message, "one\ntwo")
XCTAssertEqual(status.symbol, "lock.fill")
XCTAssertEqual(status.tone, .warning)
}

func testRefusedIsErrorWhenAnyMessageIsAnError() {
let status = present(.refused([message(type: "warning"), message(type: "error")]))
XCTAssertEqual(status.tone, .error)
}

func testFailedCarriesTheServerMessageAsError() {
let status = present(.failed("Something broke"))
XCTAssertEqual(status.message, "Something broke")
XCTAssertEqual(status.symbol, "exclamationmark.triangle.fill")
XCTAssertEqual(status.tone, .error)
}
}
112 changes: 112 additions & 0 deletions projects/ios-readplace/Tests/ShareURLExtractorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import UniformTypeIdentifiers
import XCTest
@testable import Readplace

@MainActor
final class ShareURLExtractorTests: XCTestCase {
private func item(_ providers: [NSItemProvider], title: String? = nil) -> NSExtensionItem {
let item = NSExtensionItem()
item.attachments = providers
if let title { item.attributedContentText = NSAttributedString(string: title) }
return item
}

private func urlProvider(_ string: String) -> NSItemProvider {
NSItemProvider(item: URL(string: string)! as NSURL, typeIdentifier: UTType.url.identifier)
}

private func textProvider(_ text: String) -> NSItemProvider {
NSItemProvider(item: text as NSString, typeIdentifier: UTType.plainText.identifier)
}

/// A PDF item provider backed by a real temp file so `loadFileRepresentation`
/// and the size check run. A `truncate`d sparse file gives a large logical size
/// without writing megabytes.
private func pdfProvider(logicalSize: Int = 64, name: String = "doc.pdf") throws -> NSItemProvider {
let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent(name)
var bytes = Data("%PDF-1.4\n".utf8)
if logicalSize > bytes.count { bytes.append(Data(count: min(logicalSize - bytes.count, 4096))) }
try bytes.write(to: url)
if logicalSize > bytes.count {
let handle = try FileHandle(forWritingTo: url)
try handle.truncate(atOffset: UInt64(logicalSize))
try handle.close()
}
let provider = try XCTUnwrap(NSItemProvider(contentsOf: url))
provider.suggestedName = name
return provider
}

func testExtractsAWebURL() async {
let shared = await ShareURLExtractor.extract(from: [item([urlProvider("https://example.com/post")])])
XCTAssertEqual(shared?.url?.absoluteString, "https://example.com/post")
XCTAssertNil(shared?.pdfProvider)
}

func testExtractsAURLFromPlainText() async {
let shared = await ShareURLExtractor.extract(from: [item([textProvider("read this https://example.com/p ok")])])
XCTAssertEqual(shared?.url?.absoluteString, "https://example.com/p")
}

func testExtractsURLDeliveredAsData() async {
let data = URL(string: "https://example.com/d")!.dataRepresentation
let provider = NSItemProvider(item: data as NSData, typeIdentifier: UTType.url.identifier)
let shared = await ShareURLExtractor.extract(from: [item([provider])])
XCTAssertEqual(shared?.url?.absoluteString, "https://example.com/d")
}

func testExtractsURLDeliveredAsString() async {
let provider = NSItemProvider(item: "https://example.com/s" as NSString, typeIdentifier: UTType.url.identifier)
let shared = await ShareURLExtractor.extract(from: [item([provider])])
XCTAssertEqual(shared?.url?.absoluteString, "https://example.com/s")
}

func testIgnoresANonWebURL() async {
let shared = await ShareURLExtractor.extract(from: [item([urlProvider("mailto:a@b.com")])])
XCTAssertNil(shared, "a mailto link is not a web URL and there is no PDF, so nothing is saveable")
}

func testFindsURLAndPDFAcrossSeparateItems() async throws {
let shared = await ShareURLExtractor.extract(from: [
item([urlProvider("https://example.com/a")]),
item([try pdfProvider()]),
])
XCTAssertEqual(shared?.url?.absoluteString, "https://example.com/a")
XCTAssertNotNil(shared?.pdfProvider)
}

func testTitleComesFromAttributedContentText() async {
let shared = await ShareURLExtractor.extract(from: [item([urlProvider("https://example.com/a")], title: "My Title")])
XCTAssertEqual(shared?.title, "My Title")
}

func testTitleFallsBackToPdfSuggestedNameWhenNoContentText() async throws {
let shared = await ShareURLExtractor.extract(from: [item([try pdfProvider(name: "report.pdf")])])
XCTAssertNil(shared?.url)
XCTAssertNotNil(shared?.pdfProvider)
XCTAssertEqual(shared?.title, "report.pdf", "with no content text the title falls back to the PDF's suggested name")
}

func testReturnsNilWhenNoURLAndNoPDF() async {
let shared = await ShareURLExtractor.extract(from: [item([textProvider("just words, no link")])])
XCTAssertNil(shared)
}

func testReturnsNilForNoItems() async {
let shared = await ShareURLExtractor.extract(from: [])
XCTAssertNil(shared)
}

func testLoadsPdfDataUnderTheCeiling() async throws {
let data = await ShareURLExtractor.loadPDFData(try pdfProvider(logicalSize: 256))
XCTAssertEqual(data?.starts(with: Data("%PDF-".utf8)), true)
}

func testLoadPdfDataRejectsAnOversizeFile() async throws {
let oversize = try pdfProvider(logicalSize: ReadplaceAPI.defaultMaxExternalContentBytes + 1)
let data = await ShareURLExtractor.loadPDFData(oversize)
XCTAssertNil(data, "a PDF over the extension's byte ceiling is not pulled into memory")
}
}
Loading