Each is named in the toolbar's menu rather than hidden behind one button
that says "page": reaching Settings here should be no harder than in a
browser. Admin appears only for an admin, because the server sends that
page to admins alone and a link for anyone else leads to a refusal.
Settings, the directory, Popular and OPML are dialogs the page opens by
name rather than routes of their own, so they are reached by calling them
once it has loaded -- prefsModal(), opmlModal(), selectFeed(':directory').
Asking a moment after the load avoids racing the page wiring them up. The
admin page is a real route and just loads. Asking for the screen already
showing runs the script without reloading, or the page would reload to sit
exactly where it already was.
This is a decision as much as a change, and worth writing down: the goal
was never a native app with no web view in it. These screens are
form-heavy, rarely opened, admin-gated in places, and they work. Rewriting
them would have been the largest part of the job for the smallest return.
The test opens the menu, chooses Settings, and looks for the page's own
dialog, so a menu that opens the page but not the thing asked for fails
rather than passing. It also found that the menu had no accessibility
label, which it should have had anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
6.7 KiB
Swift
134 lines
6.7 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")
|
|
}
|
|
}
|
|
|
|
/// The screens that stay in the page have to be reachable from the native interface, or they
|
|
/// are simply gone: settings, the directory, OPML and the admin page were never rewritten.
|
|
final class PageScreenTests: XCTestCase {
|
|
func testSettingsOpensThePagesOwnDialog() 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")
|
|
|
|
app.buttons["More"].firstMatch.tap()
|
|
app.buttons["Settings"].firstMatch.tap()
|
|
|
|
// The page's own Settings dialog, not a native one: this is the thing being checked.
|
|
XCTAssertTrue(app.staticTexts["Theme"].waitForExistence(timeout: 20),
|
|
"the page opened but its Settings dialog never did")
|
|
XCTAssertTrue(app.staticTexts["Subscriptions"].firstMatch.exists,
|
|
"the dialog is there but not the one expected")
|
|
}
|
|
}
|