186 lines
10 KiB
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:"))
|
|
}
|
|
}
|