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.
152 lines
8 KiB
Swift
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:"))
|
|
}
|
|
}
|