The first native surface, and the one that settles how the rest will look. Glass.swift is not a port of the stylesheet. ipx's Glass is already an approximation of what UIKit gives away -- panels are a 70% colour under blur(24px) saturate(180%) with an inset top highlight, which is a material, and the palette names Apple's own colours in its own comments. So where the CSS had to write #409cff in dark and #0055aa in light, because no single blue clears AA over the wash, this asks for the system blue and gets both. Only the wash keeps its hex values: there the colour is the design and there is no system equivalent to ask for. PlayerBarView draws what Playback knows, and Playback is the same object the lock screen and the remote commands drive, so all of them agree without anything being kept in step by hand. Back 15 and forward 30 match the page and the lock screen, so every way of skipping moves by the same amount. Seeking waits for the finger to lift rather than sending a stream of seeks at a player still answering the last one. The page's own bar is hidden rather than left to sit under ours. Its play buttons still work -- they post to the host either way -- but two bars for one player would be two sets of buttons disagreeing about what is playing, and only one of them is what CarPlay will be driving. One test had to be rewritten rather than the code: it asserted that one of the fixture's two episodes had a file, which stops being true the moment the event test asks the daemon to scan. The daemon is shared and keeps what earlier tests did to it, so the assertion now asks what the downloaded filter means instead of how many things match it today. Thirteen pass, twice over the same accumulated state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
5.9 KiB
Swift
127 lines
5.9 KiB
Swift
import XCTest
|
|
@testable import iPodderX
|
|
|
|
/// The client against a real daemon, because the shapes it decodes are the server's and a
|
|
/// hand-written fixture would only prove the fixture matches itself.
|
|
///
|
|
/// Point it at the daemon the browser suite uses, in ipodderx-rs:
|
|
///
|
|
/// node -e "require('./tests/ui/global-setup').prepare()"
|
|
/// node tests/ui/fixtures/serve.js &
|
|
/// IPX_CONFIG=$ROOT/config/config.toml IPX_DATA_DIR=$ROOT/data ./target/debug/ipx daemon &
|
|
/// TEST_RUNNER_IPX_SERVER=http://127.0.0.1:8791 TEST_RUNNER_IPX_TOKEN=<fixture token> \
|
|
/// xcodebuild test ...
|
|
final class APITests: XCTestCase {
|
|
var api: API!
|
|
|
|
override func setUpWithError() throws {
|
|
let env = ProcessInfo.processInfo.environment
|
|
let server = env["IPX_SERVER"] ?? ""
|
|
let token = env["IPX_TOKEN"] ?? ""
|
|
try XCTSkipIf(server.isEmpty, "set IPX_SERVER to a fixture daemon")
|
|
|
|
ServerSettings.base = try XCTUnwrap(ServerSettings.parse(server))
|
|
|
|
// The shared token signs in as the admin. It goes in as a cookie rather than on the
|
|
// query because that is how the client authenticates everything -- ipx reads
|
|
// HTTPCookieStorage through the same path the web view fills.
|
|
if !token.isEmpty, let url = ServerSettings.base {
|
|
let cookie = try XCTUnwrap(HTTPCookie(properties: [
|
|
.name: "ipx_token", .value: token, .path: "/",
|
|
.domain: url.host ?? "127.0.0.1",
|
|
]))
|
|
HTTPCookieStorage.shared.setCookie(cookie)
|
|
}
|
|
api = API()
|
|
}
|
|
|
|
func testSignedIn() async throws {
|
|
let me = try await api.me()
|
|
XCTAssertFalse(me.name.isEmpty, "nobody is signed in")
|
|
}
|
|
|
|
func testFeedsDecode() async throws {
|
|
let feeds = try await api.feeds()
|
|
XCTAssertFalse(feeds.isEmpty, "the fixture daemon has five feeds configured")
|
|
let show = try XCTUnwrap(feeds.first { $0.id == "test-show" }, "no test-show feed")
|
|
XCTAssertEqual(show.name, "Test Show")
|
|
XCTAssertEqual(show.entries, 2)
|
|
XCTAssertGreaterThanOrEqual(show.subscribers, 1)
|
|
}
|
|
|
|
func testEntriesPageAndDecodeEnclosures() async throws {
|
|
let page = try await api.entries(feed: "test-show")
|
|
XCTAssertEqual(page.total, 2)
|
|
let second = try XCTUnwrap(page.entries.first { $0.guid == "ui-2" })
|
|
XCTAssertEqual(second.feedId, "test-show")
|
|
// The cap is one download per scan, newest first, so the second episode has the file.
|
|
let file = try XCTUnwrap(second.playable, "the downloaded episode has no playable file")
|
|
XCTAssertTrue(file.isDownloaded)
|
|
XCTAssertEqual(file.mime, "audio/mpeg")
|
|
XCTAssertFalse(file.isVideo)
|
|
}
|
|
|
|
/// An image enclosure is on disk and is not a thing to hand a player.
|
|
func testAnImageIsNotPlayable() async throws {
|
|
let page = try await api.entries(feed: "picture-blog")
|
|
let enclosure = try XCTUnwrap(page.entries.first?.enclosures.first)
|
|
XCTAssertTrue(enclosure.isDownloaded)
|
|
XCTAssertFalse(enclosure.isPlayable, "a downloaded JPEG is not something to play")
|
|
}
|
|
|
|
/// Note that the daemon is shared and keeps what earlier tests did to it: the event test
|
|
/// asks it to scan, which downloads another episode. So this asks what the filter means
|
|
/// rather than how many things happen to match it today.
|
|
func testFilterAndSortAreTheServersWork() async throws {
|
|
let all = try await api.entries(feed: "test-show", filter: .all)
|
|
let downloaded = try await api.entries(feed: "test-show", filter: .downloaded)
|
|
XCTAssertLessThanOrEqual(downloaded.total, all.total)
|
|
XCTAssertTrue(downloaded.entries.allSatisfy { $0.enclosures.contains(where: \.isDownloaded) },
|
|
"the downloaded filter returned an item with no file")
|
|
|
|
let asc = try await api.entries(feed: "test-show", sort: .title, direction: .asc)
|
|
let desc = try await api.entries(feed: "test-show", sort: .title, direction: .desc)
|
|
XCTAssertEqual(asc.entries.map(\.guid), desc.entries.map(\.guid).reversed())
|
|
}
|
|
|
|
func testSearchMatchesTitles() async throws {
|
|
let hit = try await api.entries(feed: "test-show", search: "Second")
|
|
XCTAssertEqual(hit.entries.count, 1)
|
|
let miss = try await api.entries(feed: "test-show", search: "nothing matches this")
|
|
XCTAssertEqual(miss.total, 0)
|
|
}
|
|
|
|
/// Read is per-person state, so writing it and reading it back is the whole contract.
|
|
func testReadRoundTrips() async throws {
|
|
let before = try await api.entries(feed: "test-show", filter: .all)
|
|
let entry = try XCTUnwrap(before.entries.first { $0.guid == "ui-1" })
|
|
|
|
try await api.setRead(!entry.read, feed: entry.feedId, guid: entry.guid)
|
|
let after = try await api.entries(feed: "test-show")
|
|
let again = try XCTUnwrap(after.entries.first { $0.guid == "ui-1" })
|
|
XCTAssertEqual(again.read, !entry.read, "the flag did not stick")
|
|
|
|
try await api.setRead(entry.read, feed: entry.feedId, guid: entry.guid)
|
|
}
|
|
|
|
func testPositionRoundTrips() async throws {
|
|
try await api.setPosition(secs: 42, duration: 900, feed: "test-show", guid: "ui-2")
|
|
let page = try await api.entries(feed: "test-show")
|
|
XCTAssertEqual(page.entries.first { $0.guid == "ui-2" }?.position, 42)
|
|
try await api.setPosition(secs: 0, duration: nil, feed: "test-show", guid: "ui-2")
|
|
}
|
|
|
|
/// A 401 has to be legible: it means the session went, and only a sign-in fixes it.
|
|
func testAnUnauthenticatedCallSaysSoPlainly() async throws {
|
|
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
|
|
do {
|
|
_ = try await api.feeds()
|
|
XCTFail("a signed-out request should not succeed")
|
|
} catch API.Failure.notSignedIn {
|
|
// what we want
|
|
} catch {
|
|
XCTFail("expected notSignedIn, got \(error)")
|
|
}
|
|
}
|
|
}
|