179 lines
6.1 KiB
Swift
179 lines
6.1 KiB
Swift
import Testing
|
|
@_spi(SGTKInternal) import Gtk
|
|
@_spi(Portico) import Portico
|
|
|
|
// MARK: - Probe types
|
|
|
|
/// A label that records how many times it was mounted per key,
|
|
/// for verifying ForEach rebuild-avoidance.
|
|
private final class MountCounter {
|
|
var counts: [Int: Int] = [:]
|
|
func inc(_ key: Int) { counts[key, default: 0] += 1 }
|
|
}
|
|
|
|
private struct ProbeRow: View {
|
|
let key: Int
|
|
let label: String
|
|
let counter: MountCounter
|
|
|
|
var body: Never { fatalError() }
|
|
|
|
init(key: Int, label: String, counter: MountCounter) {
|
|
self.key = key; self.label = label; self.counter = counter
|
|
}
|
|
}
|
|
|
|
@_spi(Portico) extension ProbeRow: Mountable {
|
|
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
|
|
counter.inc(key)
|
|
return Gtk.Label(str: label)
|
|
}
|
|
}
|
|
|
|
/// A shared mutable dictionary for tracking per-key subscription fires.
|
|
private final class FireCounter {
|
|
var fires: [Int: Int] = [:]
|
|
func inc(_ key: Int) { fires[key, default: 0] += 1 }
|
|
}
|
|
|
|
/// A row that subscribes to a StateBox so we can verify the subscription
|
|
/// is cancelled on row removal.
|
|
private struct SubscribingRow: View {
|
|
let key: Int
|
|
let box: StateBox<Int>
|
|
let counter: FireCounter
|
|
var body: Never { fatalError() }
|
|
|
|
init(key: Int, box: StateBox<Int>, counter: FireCounter) {
|
|
self.key = key; self.box = box; self.counter = counter
|
|
}
|
|
}
|
|
|
|
@_spi(Portico) extension SubscribingRow: 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: "row \(key)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor @Suite(.serialized) struct ForEachTests {
|
|
|
|
// Helper: collect visible child labels from a Gtk.Box in order.
|
|
private func childLabels(_ box: Gtk.Box) -> [String] {
|
|
var result: [String] = []
|
|
var child = box.getFirstChild()
|
|
while let w = child {
|
|
// getFirstChild returns base Widget; reconstruct Label from pointer.
|
|
let lbl = Gtk.Label(retaining: w.pointer)
|
|
result.append(lbl.getText())
|
|
child = w.getNextSibling()
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Helper: count the children of a Gtk.Box.
|
|
private func childCount(_ box: Gtk.Box) -> Int {
|
|
var n = 0
|
|
var child = box.getFirstChild()
|
|
while let w = child { n += 1; child = w.getNextSibling() }
|
|
return n
|
|
}
|
|
|
|
@Test func initialMountRendersRowsInOrder() {
|
|
guard Gtk.initCheck() else { return }
|
|
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
|
|
let binding = Binding(items)
|
|
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
|
|
Label(title)
|
|
}
|
|
let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
|
|
#expect(childCount(box) == 3)
|
|
#expect(childLabels(box) == ["One", "Two", "Three"])
|
|
}
|
|
|
|
@Test func appendAddsRowWithoutRebuild() {
|
|
guard Gtk.initCheck() else { return }
|
|
let counter = MountCounter()
|
|
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
|
|
let binding = Binding(items)
|
|
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
|
|
ProbeRow(key: id, label: title, counter: counter)
|
|
}
|
|
let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
|
|
#expect(childCount(box) == 3)
|
|
#expect(counter.counts[1] == 1)
|
|
#expect(counter.counts[2] == 1)
|
|
#expect(counter.counts[3] == 1)
|
|
|
|
var arr = items.get()
|
|
arr.append((4, "Four"))
|
|
items.set(arr)
|
|
#expect(childCount(box) == 4)
|
|
// Previously-mounted keys still counted once (never rebuilt).
|
|
#expect(counter.counts[1] == 1)
|
|
#expect(counter.counts[2] == 1)
|
|
#expect(counter.counts[3] == 1)
|
|
#expect(counter.counts[4] == 1)
|
|
}
|
|
|
|
@Test func removeDropsRowAndTearsDownSubscription() {
|
|
guard Gtk.initCheck() else { return }
|
|
let box = StateBox(0)
|
|
let fires = FireCounter()
|
|
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
|
|
let binding = Binding(items)
|
|
|
|
// Use actual Gtk.Labels with subscriptions that increment fires.
|
|
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
|
|
SubscribingRow(key: id, box: box, counter: fires)
|
|
}
|
|
let container = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
|
|
#expect(childCount(container) == 3)
|
|
|
|
// Fire all: every row's subscription fires.
|
|
box.set(1)
|
|
#expect(fires.fires[1] == 1 && fires.fires[2] == 1 && fires.fires[3] == 1)
|
|
|
|
// Remove the middle element.
|
|
var arr = items.get()
|
|
arr.remove(at: 1) // remove key 2
|
|
items.set(arr)
|
|
#expect(childCount(container) == 2)
|
|
#expect(childLabels(container) == ["row 1", "row 3"])
|
|
|
|
// Fire again: removed row 2's subscription must NOT respond.
|
|
box.set(2)
|
|
#expect(fires.fires[1] == 2)
|
|
#expect(fires.fires[2] == 1) // unchanged — subscription was cancelled
|
|
#expect(fires.fires[3] == 2)
|
|
}
|
|
|
|
@Test func reorderPreservesWidgetsNoRebuild() {
|
|
guard Gtk.initCheck() else { return }
|
|
let counter = MountCounter()
|
|
let items = StateBox([(1, "One"), (2, "Two"), (3, "Three")])
|
|
let binding = Binding(items)
|
|
let forEach = ForEach(binding, id: \.0) { (id: Int, title: String) in
|
|
ProbeRow(key: id, label: title, counter: counter)
|
|
}
|
|
let box = AnyView(forEach).makeWidget(MountContext()) as! Gtk.Box
|
|
#expect(childLabels(box) == ["One", "Two", "Three"])
|
|
#expect(counter.counts[1] == 1)
|
|
#expect(counter.counts[2] == 1)
|
|
#expect(counter.counts[3] == 1)
|
|
|
|
// Reverse the array.
|
|
var arr = items.get()
|
|
arr.reverse()
|
|
items.set(arr)
|
|
#expect(childLabels(box) == ["Three", "Two", "One"])
|
|
// Mount counts unchanged — identity preserved.
|
|
#expect(counter.counts[1] == 1)
|
|
#expect(counter.counts[2] == 1)
|
|
#expect(counter.counts[3] == 1)
|
|
}
|
|
}
|