portico/Tests/PorticoTests/ForEachTests.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(str: 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)
}
}