The reading pane: the item, its files with play, download and delete, read and pin in the toolbar, a link to the original, and the notes. The notes are a web view, deliberately. They are feed-supplied HTML that ipx has already run through ammonia, and there is no good native renderer for that -- NSAttributedString(html:) is slow, single-threaded and ugly, and writing a real one is a project. So this carries only the notes, with just enough stylesheet to belong to the app: the system font at body size, the label colours, links in the accent. Nothing else is imposed, because the markup is the publisher's. A link opens in Safari rather than inside the view, where it would replace the notes with somebody's website and leave no way back. Deleting asks first, and says when the file is shared: one file serves everyone reading the feed, so removing it is not a private act. Two goes at the navigation. A row with a selection binding on the list highlighted and went nowhere -- the binding takes the tap before the link sees it. Without the binding, NavigationLink(value:) with a matching navigationDestination still pushed nothing and gave no reason, so the link carries its own destination now, which has nothing to get wrong. Fourteen tests pass. The new one opens an item and looks for its notes, its delete button and its read control, so a pane that renders empty fails rather than passing quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
5.6 KiB
Swift
109 lines
5.6 KiB
Swift
import XCTest
|
|
|
|
/// Playing an episode has to leave the page, reach the host's player, and be written back.
|
|
///
|
|
/// Point it at the daemon the browser suite uses, which has fixture feeds and real files:
|
|
///
|
|
/// node -e "require('./tests/ui/global-setup').prepare()" # in ipodderx-rs
|
|
/// 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/?token=<the fixture token>" xcodebuild test ...
|
|
///
|
|
/// TEST_RUNNER_ is not decoration: the test runs in its own process on the simulator, and that
|
|
/// prefix is what carries a variable across to it.
|
|
final class PlaybackTests: XCTestCase {
|
|
/// Not "First Episode". The fixture caps downloads at one per scan and takes the newest, so
|
|
/// the second is the one with a file behind it -- and an episode with nothing downloaded
|
|
/// offers Download rather than Play.
|
|
let feed = "test-show", guid = "ui-2", title = "Second Episode"
|
|
|
|
func testPlayingAnEpisodeReachesTheHostAndComesBack() throws {
|
|
let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? ""
|
|
try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a daemon with an episode downloaded")
|
|
let base = try XCTUnwrap(URL(string: server))
|
|
|
|
// Unread first, so that becoming read again can only be playback's doing. Set through
|
|
// the API rather than by driving the interface: what is being tested is the playing.
|
|
try setRead(false, in: base)
|
|
|
|
let app = XCUIApplication()
|
|
app.launchArguments = ["-ipx.server", server]
|
|
app.launch()
|
|
|
|
let episode = app.staticTexts[title]
|
|
XCTAssertTrue(episode.waitForExistence(timeout: 30), "the list never showed the fixture episode")
|
|
|
|
// The row's own play button. Every row with a downloaded file has one.
|
|
let play = app.buttons["Play"].firstMatch
|
|
XCTAssertTrue(play.waitForExistence(timeout: 15),
|
|
"no row offers to play, so nothing in the list has a downloaded file")
|
|
play.tap()
|
|
|
|
// Read again, rather than a position: the browser suite's fixture is a sixth of a second
|
|
// long and a position that rounds to zero is never written. Read is set at the end of an
|
|
// episode, by the host, which is the whole round trip.
|
|
XCTAssertTrue(waitForRead(in: base, timeout: 30),
|
|
"the episode never came back read, so the host never played it through")
|
|
}
|
|
|
|
/// Sets read through ipx directly, to put the fixture in a known state first.
|
|
private func setRead(_ read: Bool, in server: URL) throws {
|
|
var components = try XCTUnwrap(URLComponents(url: server, resolvingAgainstBaseURL: false))
|
|
components.path = "/api/entries/\(feed)/\(guid)/flags"
|
|
var req = URLRequest(url: try XCTUnwrap(components.url))
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try JSONSerialization.data(withJSONObject: ["read": read])
|
|
let done = expectation(description: "flags written")
|
|
URLSession.shared.dataTask(with: req) { _, _, _ in done.fulfill() }.resume()
|
|
wait(for: [done], timeout: 15)
|
|
}
|
|
|
|
private func entry(in server: URL) -> [String: Any]? {
|
|
var components = URLComponents(url: server, resolvingAgainstBaseURL: false)!
|
|
components.path = "/api/entries"
|
|
guard let url = components.url, let data = try? Data(contentsOf: url),
|
|
let page = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let entries = page["entries"] as? [[String: Any]] else { return nil }
|
|
return entries.first { $0["guid"] as? String == guid }
|
|
}
|
|
|
|
/// Polls until the server says the episode is read. Its own request, not the app's -- the
|
|
/// point is to ask the server what it ended up with.
|
|
private func waitForRead(in server: URL, timeout: TimeInterval) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if entry(in: server)?["read"] as? Bool == true { return true }
|
|
Thread.sleep(forTimeInterval: 1)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Opening an item, which is a push away from the list and has to show what is in it.
|
|
final class ItemPaneTests: XCTestCase {
|
|
func testOpeningAnItemShowsItsFilesAndNotes() throws {
|
|
let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? ""
|
|
try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a fixture daemon")
|
|
|
|
let app = XCUIApplication()
|
|
app.launchArguments = ["-ipx.server", server]
|
|
app.launch()
|
|
|
|
XCTAssertTrue(app.staticTexts["Second Episode"].firstMatch.waitForExistence(timeout: 30),
|
|
"the list never appeared")
|
|
// The row, not its title: tapping the label does not activate the link, and with a
|
|
// selection binding on the list the tap was swallowed before the link ever saw it.
|
|
app.cells.element(boundBy: 0).tap()
|
|
|
|
// The pane is titled with the feed, and carries the item's file and its notes.
|
|
XCTAssertTrue(app.staticTexts["Notes for the second."].waitForExistence(timeout: 15),
|
|
"the show notes did not render")
|
|
XCTAssertTrue(app.buttons["Delete file"].firstMatch.exists,
|
|
"the downloaded file is not offered for deletion")
|
|
XCTAssertTrue(app.buttons["Mark unread"].firstMatch.exists
|
|
|| app.buttons["Mark read"].firstMatch.exists,
|
|
"no read control in the pane")
|
|
}
|
|
}
|