portico/Tests/PorticoTests/NavigationViewPathTests.swift

331 lines
12 KiB
Swift

import Foundation
import Testing
import Adw
import Gio
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@_silgen_name("g_main_context_iteration")
private nonisolated func nav_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Drives the GLib main context until `condition` holds or `turns` is exhausted.
@MainActor private func pump(until condition: () -> Bool, turns: Int = 200) {
for _ in 0..<turns {
if condition() { return }
_ = nav_g_main_context_iteration(nil, 0)
}
}
struct Park: Hashable, Codable { let name: String }
struct City: Hashable, Codable { let name: String }
private struct Unregistered: Hashable { let id: Int }
private func depth(_ nav: Adw.NavigationView) -> Int {
Int(nav.navigationStack.getNItems())
}
// MARK: - Probe types
/// A label that records how many times it was mounted per key.
private final class MountCounter {
var counts: [String: Int] = [:]
func inc(_ key: String) { counts[key, default: 0] += 1 }
}
private struct ProbePage: View {
let key: String
let counter: MountCounter
var body: Never { fatalError() }
init(key: String, counter: MountCounter) {
self.key = key; self.counter = counter
}
}
@_spi(Portico) extension ProbePage: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
counter.inc(key)
return Gtk.Label(str: key)
}
}
/// A shared mutable dictionary for tracking per-key subscription fires.
private final class FireCounter {
var fires: [String: Int] = [:]
func inc(_ key: String) { fires[key, default: 0] += 1 }
}
/// A page that subscribes to a StateBox so we can verify the subscription
/// is cancelled on page removal.
private struct SubscribingPage: View {
let key: String
let box: StateBox<Int>
let counter: FireCounter
var body: Never { fatalError() }
init(key: String, box: StateBox<Int>, counter: FireCounter) {
self.key = key; self.box = box; self.counter = counter
}
}
@_spi(Portico) extension SubscribingPage: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
ctx.registry.add(box.subscribe { [key, counter] _ in
counter.inc(key)
})
return Gtk.Label(str: "sub \(key)")
}
}
// MARK: - Tests
@MainActor @Suite(.serialized) struct NavigationViewPathTests {
// MARK: Static init
@Test func staticInitMountsRootOnly() {
guard Gtk.initCheck() else { return }
let ctx = MountContext()
let view = Portico.NavigationView { Label(str: "root") }
let w = AnyView(view).makeWidget(ctx) as! Adw.NavigationView
#expect(depth(w) == 1)
#expect(w.getVisiblePage()?.getTag() == "portico.nav.root")
#expect(ctx.registry.isEmpty == true)
}
@Test func generatedNoArgInitStillMountsEmpty() {
guard Gtk.initCheck() else { return }
let ctx = MountContext()
let view = Portico.NavigationView()
let w = AnyView(view).makeWidget(ctx) as! Adw.NavigationView
#expect(depth(w) == 0)
#expect(ctx.registry.isEmpty == true)
}
// MARK: Push / pop
@Test func appendPushesDestinationPage() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
#expect(depth(w) == 1)
box.set([Park(name: "Yosemite")])
#expect(depth(w) == 2)
#expect(counter.counts["Yosemite"] == 1)
}
@Test func truncatePopsPage() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "Yosemite"), Park(name: "Zion")])
#expect(depth(w) == 3)
box.set([Park(name: "Yosemite")])
#expect(depth(w) == 2)
}
@Test func commonPrefixSurvivesAppend() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "A")])
#expect(counter.counts["A"] == 1)
box.set([Park(name: "A"), Park(name: "B")])
#expect(depth(w) == 3)
#expect(counter.counts["A"] == 1)
#expect(counter.counts["B"] == 1)
}
@Test func divergenceReplacesTailAndKeepsPrefix() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "A"), Park(name: "B")])
#expect(counter.counts["A"] == 1)
#expect(counter.counts["B"] == 1)
box.set([Park(name: "A"), Park(name: "C")])
#expect(depth(w) == 3)
#expect(counter.counts["A"] == 1)
#expect(counter.counts["C"] == 1)
}
// MARK: NavigationPath
@Test func navigationPathRoutesByDynamicType() {
guard Gtk.initCheck() else { return }
let box = StateBox(NavigationPath())
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
.navigationDestination(for: City.self) { city in
ProbePage(key: city.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
var path = NavigationPath()
path.append(Park(name: "Yosemite"))
path.append(City(name: "Portland"))
box.set(path)
#expect(depth(w) == 3)
#expect(counter.counts["Yosemite"] == 1)
#expect(counter.counts["Portland"] == 1)
}
// MARK: Widget pop
@Test func widgetPopWritesBackToPath() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
Label(str: park.name)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "Yosemite"), Park(name: "Zion")])
#expect(depth(w) == 3)
_ = w.pop()
pump(until: { box.get().count == 1 })
#expect(box.get().count == 1)
#expect(depth(w) == 2)
}
// MARK: Teardown
@Test func poppedPageTearsDownSubscriptions() {
guard Gtk.initCheck() else { return }
let pathBox = StateBox<[Park]>([])
let subBox = StateBox(0)
let counter = FireCounter()
let view = Portico.NavigationView(path: Binding(pathBox)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
SubscribingPage(key: park.name, box: subBox, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
pathBox.set([Park(name: "Yosemite")])
#expect(depth(w) == 2)
subBox.set(1)
#expect(counter.fires["Yosemite"] == 1)
pathBox.set([])
subBox.set(2)
// Teardown should have cancelled the subscription; no second fire.
#expect(counter.fires["Yosemite"] == 1)
}
// MARK: Missing destination
@Test func missingDestinationKeepsPathAligned() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Unregistered]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Unregistered(id: 1)])
#expect(depth(w) == 2)
box.set([])
#expect(depth(w) == 1)
}
// MARK: navigationTitle
@Test func navigationTitleSetsPageTitle() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
Label(str: park.name).navigationTitle(park.name)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "Yosemite")])
pump(until: { w.getVisiblePage()?.getTitle() == "Yosemite" })
#expect(w.getVisiblePage()?.getTitle() == "Yosemite")
}
@Test func navigationTitleBindingUpdatesLive() {
guard Gtk.initCheck() else { return }
let pathBox = StateBox<[Park]>([])
let titleBox = StateBox("Initial")
let view = Portico.NavigationView(path: Binding(pathBox)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
Label(str: park.name).navigationTitle(Binding(titleBox))
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
pathBox.set([Park(name: "Yosemite")])
pump(until: { w.getVisiblePage()?.getTitle() == "Initial" }, turns: 400)
#expect(w.getVisiblePage()?.getTitle() == "Initial")
titleBox.set("Renamed")
pump(until: { w.getVisiblePage()?.getTitle() == "Renamed" }, turns: 400)
#expect(w.getVisiblePage()?.getTitle() == "Renamed")
}
// MARK: Inherited modifier
@Test func inheritedModifierStillApplies() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
}.popOnEscape(false)
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
#expect(w.getPopOnEscape() == false)
}
// MARK: Codable
@Test func navigationPathCodableRoundTrip() throws {
var path = NavigationPath()
path.append(Park(name: "Yosemite"))
path.append(City(name: "Portland"))
let rep = try #require(path.codable)
let data = try JSONEncoder().encode(rep)
let decoded = try JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data)
let restored = NavigationPath(decoded)
#expect(restored == path)
}
@Test func navigationPathCodableNilForNonCodableElement() {
var path = NavigationPath()
path.append(Unregistered(id: 1))
#expect(path.codable == nil)
}
}