331 lines
12 KiB
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)
|
|
}
|
|
}
|