1
0
Fork 0
gobject-generator/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift
Brendan Szymanski dac435ec89 Add Tier 4 Gdk/Gsk bindings and E3 review remediation
Generates Gdk and Gsk wrappers (330/417 and 140/183 callables), with runtime smoke tests linked against real libgtk-4. Also fixes a latent cross-module dropped-type reference bug, corrects the init(takingOwnership:) doc for no-free records, restores a precise filename safety check, and adds unit test coverage for four previously compile-gate-only branches.
2026-07-20 12:37:54 -04:00

152 lines
8 KiB
Swift

// SignalGenerationTests.swift
// Covers Phase D signal generation: `@_cdecl nonisolated` trampolines that
// hop to `MainActor.assumeIsolated` before constructing typed wrappers and
// invoking the user's closure, plus the `connect<Name>` method that boxes the
// closure, wires the ABI-correct 2-arg `GClosureNotify` destroy callback into
// `g_signal_connect_data`, and returns a `SignalHandle`.
import Testing
@testable import SwiftGtkGenCore
@Suite("Signal generation")
struct SignalGenerationTests {
/// A GObject-local context registering a root `Object` class the
/// minimal registry needed to plan a signal whose instance param resolves
/// to a known class.
func makeContext() -> MapContext {
let gobject = Repository(namespaces: [
Namespace(
name: "GObject", version: "2.0",
classes: [
Class(name: "Object", cType: "GObject", parent: nil,
getTypeFunction: "g_object_get_type"),
Class(name: "ParamSpec", cType: "GParamSpec", parent: nil,
getTypeFunction: "g_param_spec_get_type"),
]
)
])
let registry = TypeRegistry(repositories: ["GObject": gobject])
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
}
/// A context whose registry additionally declares two boxed records: one
/// with a copy function (wraps via `init(retaining:)`), one without
/// (wraps via `init(takingOwnership:)`, mirroring `GdkToplevelSize`).
func makeBoxedContext() -> MapContext {
let gobject = Repository(namespaces: [
Namespace(
name: "GObject", version: "2.0",
records: [
Record(name: "Value", cType: "GValue", getTypeFunction: "g_value_get_type",
copyFunction: "g_value_copy", freeFunction: "g_value_free"),
Record(name: "NoCopyBox", cType: "GNoCopyBox", getTypeFunction: "g_no_copy_box_get_type"),
]
)
])
let registry = TypeRegistry(repositories: ["GObject": gobject])
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
}
func renderClass(named name: String, signals: [Signal], context: MapContext) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) {
let klass = Class(name: name, cType: "G\(name)", parent: nil,
getTypeFunction: "g_\(name.lowercased())_get_type",
signals: signals)
let (plan, skips) = planClass(klass, context: context)
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: skips,
coverage: CoverageStats())
return (renderModule(module)["\(name).swift"] ?? "", plan, skips)
}
/// Plans a one-off class carrying `signals` and renders it, returning the
/// class file body plus the plan and skips.
func renderClass(named name: String, signals: [Signal]) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) {
let klass = Class(name: name, cType: "G\(name)", parent: nil,
getTypeFunction: "g_\(name.lowercased())_get_type",
signals: signals)
let (plan, skips) = planClass(klass, context: makeContext())
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: skips,
coverage: CoverageStats())
return (renderModule(module)["\(name).swift"] ?? "", plan, skips)
}
@Test("Trampoline emits a @_cdecl nonisolated func with the correct C parameter list")
func trampolineCSignature() throws {
let notify = Signal(
name: "notify",
parameters: [Parameter(name: "pspec", type: .pointer, cType: "GParamSpec*")],
isDetailed: true
)
let (source, plan, skips) = renderClass(named: "Object", signals: [notify])
#expect(skips.isEmpty)
#expect(plan.signals.count == 1)
#expect(source.contains("@_cdecl(\"_trampoline_GObject_Object_notify\")"))
#expect(source.contains("nonisolated func _trampoline_GObject_Object_notify("))
#expect(source.contains("_ instance: UnsafeMutableRawPointer"))
#expect(source.contains("_ data: UnsafeMutableRawPointer?"))
// Body re-enters MainActor before touching the raw pointers.
#expect(source.contains("MainActor.assumeIsolated"))
}
@Test("isDetailed: true renders a detail parameter; isDetailed: false does not")
func detailedVsNonDetailedSignature() throws {
let detailed = Signal(name: "notify", isDetailed: true)
let bare = Signal(name: "destroy", isDetailed: false)
let (source, plan, skips) = renderClass(named: "Widget", signals: [detailed, bare])
#expect(skips.isEmpty)
#expect(plan.signals.count == 2)
#expect(source.contains("func connectNotify(detail:"))
#expect(source.contains("func connectDestroy(_ handler:"))
#expect(!source.contains("func connectDestroy(detail:"))
}
@Test("An unmappable signal parameter type produces a skip, not a partial plan")
func unmappableParamSkip() throws {
// `GIRType.typeRef` to an unregistered type never resolves the
// planner must skip the whole signal rather than emit a broken plan.
let badSignal = Signal(
name: "weird",
parameters: [Parameter(name: "thing", type: .typeRef("Nonexistent", namespace: "GObject"),
cType: "GNonexistent*")]
)
let (source, plan, skips) = renderClass(named: "Emitter", signals: [badSignal])
#expect(plan.signals.isEmpty)
#expect(skips.contains { $0.reason == .signalUnmappableParam })
#expect(!source.contains("connectWeird"))
}
@Test("connect method wires the ABI-correct 2-arg destroy callback into g_signal_connect_data")
func destroyNotifyWiring() throws {
let notify = Signal(name: "notify", isDetailed: true)
let (source, _, skips) = renderClass(named: "Object", signals: [notify])
#expect(skips.isEmpty)
// The destroy closure matches GClosureNotify's 2-arg C signature
// (gpointer data, GClosure *closure) not GDestroyNotify's 1-arg form.
#expect(source.contains("@convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Void"))
#expect(source.contains("_sgtk_destroy_notify_impl(data, nil)"))
// The wired destroy arg is passed (non-nil) to the connect call
// the D3 leak regression this test guards against.
#expect(source.contains("unsafeBitCast(destroyFn as (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> Void), to: UnsafeRawPointer.self)"))
#expect(source.contains("_sgtk_signal_connect_data("))
#expect(!source.contains("_sgtk_signal_connect_data(ptr, cName, unsafeBitCast(\\(plan.trampolineCName)"))
}
@Test("Boxed signal param wraps via retaining: when a copy function exists, takingOwnership: when it doesn't")
func boxedSignalParamWrapperSelection() throws {
let withCopy = Signal(
name: "value-changed",
parameters: [Parameter(name: "value", type: .typeRef("Value", namespace: "GObject"),
cType: "GValue*")]
)
let withoutCopy = Signal(
name: "box-changed",
parameters: [Parameter(name: "box", type: .typeRef("NoCopyBox", namespace: "GObject"),
cType: "GNoCopyBox*")]
)
let (source, plan, skips) = renderClass(named: "Emitter", signals: [withCopy, withoutCopy], context: makeBoxedContext())
#expect(skips.isEmpty)
#expect(plan.signals.count == 2)
#expect(source.contains("Value(retaining:"))
#expect(source.contains("NoCopyBox(takingOwnership:"))
#expect(!source.contains("NoCopyBox(retaining:"))
}
}