1
0
Fork 0
gobject-generator/Tests/GObjectGeneratorCoreTests/SignalGenerationTests.swift

186 lines
10 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 GObjectGeneratorCore
@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(\"_trampolineGObjectObjectNotify\")"))
#expect(source.contains("nonisolated func _trampolineGObjectObjectNotify("))
#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("_sgtkDestroyNotifyImpl(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("_sgtkSignalConnectData("))
#expect(!source.contains("_sgtkSignalConnectData(ptr, cName, unsafeBitCast(\\(plan.trampolineCName)"))
}
@Test("Support.swift hides GLibError.init(consuming:), SignalHandle.instance/init, and _sgtk_* helpers behind @_spi(SGTKInternal)")
func supportPointerSurfaceIsSPIGated() throws {
let notify = Signal(name: "notify", isDetailed: true)
let klass = Class(name: "Object", cType: "GObject", parent: nil,
getTypeFunction: "g_object_get_type", signals: [notify])
let (plan, skips) = planClass(klass, context: makeContext())
#expect(skips.isEmpty)
let module = ModulePlan(module: "GObject", dependencyModules: ["GLib"],
types: [.class(plan)], skips: [], coverage: CoverageStats())
let support = renderModule(module)["Support.swift"] ?? ""
#expect(support.contains("@_spi(SGTKInternal) public let instance: UnsafeMutableRawPointer"))
#expect(support.contains("@_spi(SGTKInternal) public init(id: UInt, instance: UnsafeMutableRawPointer)"))
#expect(support.contains("@_spi(SGTKInternal) public nonisolated func _sgtkDestroyNotifyImpl("))
#expect(support.contains("@_spi(SGTKInternal) public nonisolated func _sgtkSignalConnectData("))
#expect(support.contains("@_spi(SGTKInternal) public nonisolated func _sgtkSignalHandlerDisconnect("))
// SignalHandle itself and its id/disconnect() stay plain public only
// the raw-pointer members are hidden.
#expect(support.contains("public struct SignalHandle {"))
#expect(support.contains("public let id: UInt"))
#expect(support.contains("public mutating func disconnect()"))
// Dependency import chokepoint is SPI too.
#expect(support.contains("@_spi(SGTKInternal) import GLib"))
}
@Test("Support.swift hides GLibError.init(consuming:) behind @_spi(SGTKInternal), struct/fields stay plain public")
func glibErrorConsumingInitIsSPIGated() throws {
let module = ModulePlan(module: "GLib", types: [], skips: [], coverage: CoverageStats())
let support = renderModule(module)["Support.swift"] ?? ""
#expect(support.contains("public struct GLibError: Swift.Error {"))
#expect(support.contains("public let domain: UInt32"))
#expect(support.contains("@_spi(SGTKInternal) public init(consuming error: UnsafeMutablePointer<GError>)"))
}
@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:"))
}
}