// 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` 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)")) } @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:")) } }