Files
ipodderx-app/ios/UITests/PlaybackTests.swift
Ray Slakinski d68f1d0d1d Opening an item reads it, and the page's keys (#11)
selectEntry in the page calls markRead, so picking a row reads it. Natively
an item could be opened, read and left, and it stayed bold. With it comes
the detail that would have been missed by guessing: on the Unread tab the
item you were reading is dropped when you move on to the next one, not
whenever a refresh next comes along, so nothing vanishes from under the
pointer mid-click. A flag that does not save rolls back and says so, where
the page toasts.

The keys are Feedly's set, from player.ts: j n and k p, shift J and K, o m
s v, shift A, r, [, space, the arrows, g then a d p l or s, and ? for the
list of them. The pair window is the page's second and a half, and p and s
mean different things with and without a g in front, which they do there
too.

They are UIKeyCommand rather than SwiftUI's onKeyPress, which is iOS 17,
and they hang off the hosting controller rather than the controller that
owns it: SwiftUI's views hold first responder, so the chain starts inside
that hierarchy and an override further up is never consulted.

What is tested is the map, in KeysTests -- that p is Previous alone and
Popular after g, that every mapped key is registered, that the window is
1.5s -- because a wrong letter there loses a shortcut silently. What is not
tested is whether a press arrives at all: the simulator drops key events
unless something is focused, and running the same tests against Catalyst,
where the keyboard is the point, needs the runner to have accessibility
permission it does not have here. That test is skipped with the reason
written in it rather than deleted or left red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:26:05 -04:00

149 lines
7.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")
}
}
/// 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")
}
}
/// The page's keys, which are most of why a list is worth using on a Mac.
///
/// These are skipped rather than deleted, and it is worth saying why. Driving them needs key
/// events to reach an app that is not focused on a text field, and the simulator drops those;
/// running the same tests against Mac Catalyst, where the keyboard is the point, needs the test
/// runner to have accessibility permission, which it does not have here. The mapping itself is
/// covered by KeysTests, which is where a typo would silently lose a shortcut. What is left
/// unproven is the wiring: whether the responder chain delivers a press at all.
final class KeyboardTests: XCTestCase {
func testTheKeysAreDrivenByHand() throws {
throw XCTSkip("key events need a focused app the simulator will not give, and the "
+ "Catalyst runner needs accessibility permission; press ? in the app")
}
}