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

460 lines
25 KiB
Swift

// PropertyGenerationTests.swift
// Covers Phase C6: GObject property planning and rendering. A property becomes
// a Swift computed `var` backed by the GValue machinery
// (`g_value_init` / `g_object_get_property` / `g_object_set_property`), rendered
// as a `public var` for interfaces, as a protocol-extension default. Properties
// whose type has no `GValueOps` are recorded as skips.
//
// These tests also lock in the numeric bridging of enum/flags getters: a
// generated Swift enum uses an `Int` raw value while `g_value_get_enum` returns
// a `gint` (Int32), so the getter must bridge through `numericCast`. Emitting a
// bare `EnumType(rawValue: g_value_get_enum(...))` fails to compile the
// regression that `enumPropertyGetterBridgesRawValueWithNumericCast` guards.
import Testing
@testable import GObjectGeneratorCore
@Suite("Property generation")
struct PropertyGenerationTests {
/// A GObject-local context: `currentModule == "GObject"` so mapped type
/// names are bare (e.g. `Object`, `Orientation`). Registers the named types
/// the property tests reference a root object class, an enum, a bitfield,
/// and an interface (used to exercise the unsupported-type skip path).
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"),
],
interfaces: [
Interface(name: "TypePlugin", cType: "GTypePlugin",
getTypeFunction: "g_type_plugin_get_type"),
],
records: [
Record(name: "Value", cType: "GValue",
getTypeFunction: "g_value_get_type",
copyFunction: "g_value_copy",
freeFunction: "g_value_free"),
],
enumerations: [
Enumeration(name: "Orientation", cType: "GtkOrientation",
members: [
EnumMember(name: "horizontal", value: "0",
cIdentifier: "GTK_ORIENTATION_HORIZONTAL"),
EnumMember(name: "vertical", value: "1",
cIdentifier: "GTK_ORIENTATION_VERTICAL"),
],
getTypeFunction: "gtk_orientation_get_type"),
],
bitfields: [
Bitfield(name: "StateFlags", cType: "GtkStateFlags",
members: [
EnumMember(name: "active", value: "1",
cIdentifier: "GTK_STATE_FLAG_ACTIVE"),
],
getTypeFunction: "gtk_state_flags_get_type"),
]
)
])
let registry = TypeRegistry(repositories: ["GObject": gobject])
return MapContext(registry: registry, currentModule: "GObject", currentNamespace: "GObject")
}
/// Plans a one-off class carrying `properties` and renders it to Swift
/// source, returning the class file body.
func renderClass(named name: String, properties: [Property],
methods: [Method] = []) -> (source: String, plan: ClassPlan, skips: [SkipEntry]) {
let klass = Class(name: name, cType: "G\(name)", parent: nil,
getTypeFunction: "g_\(name.lowercased())_get_type",
methods: methods, properties: properties)
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)
}
/// A `self` instance parameter for building test methods.
var selfParam: Parameter {
Parameter(name: "self", type: .pointer, cType: "GThing*", isInstanceParameter: true)
}
/// Builds an instance getter method `get_<x>` returning `type`.
func getterMethod(_ girName: String, returns type: GIRType, nullable: Bool = false) -> Method {
Method(name: girName, cIdentifier: "g_thing_\(girName)",
parameters: [selfParam],
returnValue: ReturnValue(type: type, isNullable: nullable))
}
/// Builds an instance setter method `set_<x>` taking one argument of `type`.
/// `cType` must be a real C spelling (e.g. `"const char*"`) for string
/// arguments, which the parameter planner requires.
func setterMethod(_ girName: String, arg argName: String, of type: GIRType,
cType: String = "const char*", nullable: Bool = false) -> Method {
Method(name: girName, cIdentifier: "g_thing_\(girName)",
parameters: [selfParam, Parameter(name: argName, type: type, cType: cType, isNullable: nullable)],
returnValue: ReturnValue(type: .void))
}
// Primitive properties (the three the plan called out explicitly)
@Test("Readable Int32 property generates a GValue-backed getter")
func readableInt32Property() throws {
let prop = Property(name: "length", type: .int32, isReadable: true, isWritable: false)
let (source, plan, skips) = renderClass(named: "Buffer", properties: [prop])
#expect(skips.isEmpty)
#expect(plan.properties.count == 1)
#expect(source.contains("public var length: Int32"))
#expect(source.contains("g_value_init(&gvalue, gTypeInt)"))
#expect(source.contains("g_value_get_int(&gvalue)"))
#expect(source.contains("g_object_get_property(_instancePointer(pointer), \"length\", &gvalue)"))
// Read-only: no setter.
#expect(!source.contains("set {"))
#expect(!source.contains("g_object_set_property"))
}
// MARK: - Phase E4: no-unsafe-pointer public API policy
@Test("A root class's pointer storage and both instance inits are @_spi(SGTKInternal), the class itself stays public")
func classPointerSurfaceIsSPIGated() throws {
let prop = Property(name: "length", type: .int32, isReadable: true, isWritable: false)
let (source, _, _) = renderClass(named: "Buffer", properties: [prop])
#expect(source.contains("public class Buffer {"))
#expect(source.contains("@_spi(SGTKInternal) public let pointer: UnsafeMutableRawPointer"))
#expect(source.contains("@_spi(SGTKInternal) public required init(takingOwnership pointer: UnsafeMutableRawPointer)"))
#expect(source.contains("@_spi(SGTKInternal) public init(retaining pointer: UnsafeMutableRawPointer)"))
// The plain-public getter is unaffected Int32 carries no raw pointer.
#expect(source.contains(" public var length: Int32"))
}
@Test("A root class's isolated deinit unrefs its pointer via g_object_unref")
func rootClassEmitsUnrefDeinit() throws {
let prop = Property(name: "length", type: .int32, isReadable: true, isWritable: false)
let (source, plan, _) = renderClass(named: "Buffer", properties: [prop])
#expect(plan.unrefFunc == "g_object_unref")
#expect(source.contains("isolated deinit {"))
#expect(source.contains("g_object_unref(pointer)"))
}
@Test("A class rooting a non-GObject fundamental unrefs through _instancePointer")
func fundamentalRootClassUnrefsTypedPointer() throws {
let plan = ClassPlan(
name: "ParamSpec", girName: "GObject.ParamSpec", cType: "GParamSpec",
getTypeFunction: "g_param_spec_get_type",
refFunc: "g_param_spec_ref", unrefFunc: "g_param_spec_unref"
)
let module = ModulePlan(module: "GObject", types: [.class(plan)], skips: [],
coverage: CoverageStats())
let source = renderModule(module)["ParamSpec.swift"] ?? ""
#expect(source.contains("isolated deinit {"))
#expect(source.contains("g_param_spec_unref(_instancePointer(pointer))"))
}
@Test("Writable string property generates both a getter and a setter")
func writableStringProperty() throws {
let prop = Property(name: "label", type: .string, isReadable: true, isWritable: true)
let (source, _, _) = renderClass(named: "Button", properties: [prop])
#expect(source.contains("public var label: String"))
#expect(source.contains("get {"))
#expect(source.contains("set {"))
#expect(source.contains("g_value_get_string(&gvalue)"))
// The string setter routes newValue through withCString.
#expect(source.contains("newValue.withCString"))
#expect(source.contains("g_value_set_string(&gvalue, cstr)"))
#expect(source.contains("g_object_set_property(_instancePointer(pointer), \"label\", &gvalue)"))
}
@Test("Read-only boolean property generates a getter only")
func readOnlyBooleanProperty() throws {
let prop = Property(name: "visible", type: .boolean, isReadable: true, isWritable: false)
let (source, _, _) = renderClass(named: "Widget", properties: [prop])
#expect(source.contains("public var visible: Bool"))
#expect(source.contains("g_value_get_boolean(&gvalue) != 0"))
#expect(!source.contains("set {"))
}
// Enum / flags numeric bridging (regression guard for the getter bug)
@Test("Enum property getter bridges the raw value through numericCast")
func enumPropertyGetterBridgesRawValueWithNumericCast() throws {
let prop = Property(name: "orientation",
type: .typeRef("Orientation", namespace: "GObject"),
isReadable: true, isWritable: false)
let (source, plan, skips) = renderClass(named: "Orientable", properties: [prop])
#expect(skips.isEmpty)
#expect(plan.properties.count == 1)
#expect(source.contains("public var orientation: Orientation"))
#expect(source.contains("g_value_init(&gvalue, gTypeEnum)"))
// `g_value_get_enum` returns Int32; the enum's raw value is Int, so the
// result MUST be numericCast a bare cast would not compile.
#expect(source.contains("Orientation(rawValue: numericCast(g_value_get_enum(&gvalue)))!"))
#expect(!source.contains("rawValue: g_value_get_enum(&gvalue))!"))
}
@Test("Flags property getter bridges the raw value through numericCast")
func flagsPropertyGetterBridgesRawValueWithNumericCast() throws {
let prop = Property(name: "state-flags",
type: .typeRef("StateFlags", namespace: "GObject"),
isReadable: true, isWritable: false)
let (source, _, skips) = renderClass(named: "Widget", properties: [prop])
#expect(skips.isEmpty)
#expect(source.contains("public var stateFlags: StateFlags"))
#expect(source.contains("g_value_init(&gvalue, gTypeFlags)"))
#expect(source.contains("StateFlags(rawValue: numericCast(g_value_get_flags(&gvalue)))"))
}
// Ownership, construct-only, naming, and object properties
@Test("Object property getter takes a retained reference (transfer none)")
func objectPropertyGetterUsesRetaining() throws {
let prop = Property(name: "child",
type: .typeRef("Object", namespace: "GObject"),
isReadable: true, isWritable: false)
let (source, _, skips) = renderClass(named: "Bin", properties: [prop])
#expect(skips.isEmpty)
#expect(source.contains("public var child: Object"))
#expect(source.contains("g_value_init(&gvalue, gTypeObject)"))
#expect(source.contains("Object(retaining: g_value_get_object(&gvalue))"))
}
@Test("Construct-only properties are treated as read-only")
func constructOnlyPropertyIsReadOnly() throws {
// Writable in GIR, but construct-only: a runtime setter would silently
// no-op, so it must render as read-only.
let prop = Property(name: "source", type: .string,
isReadable: true, isWritable: true, isConstructOnly: true)
let (source, plan, _) = renderClass(named: "Binding", properties: [prop])
#expect(plan.properties.count == 1)
#expect(plan.properties[0].setter == nil)
#expect(source.contains("public var source: String"))
#expect(!source.contains("set {"))
#expect(!source.contains("g_object_set_property"))
}
@Test("A kebab-case GIR name yields a camelCase Swift name and a preserved GObject name")
func girNamePreservedWhileSwiftNameCamelCased() throws {
let prop = Property(name: "source-property", type: .string,
isReadable: true, isWritable: false)
let (source, plan, _) = renderClass(named: "Binding", properties: [prop])
#expect(plan.properties[0].swiftName == "sourceProperty")
#expect(plan.properties[0].girName == "source-property")
#expect(source.contains("public var sourceProperty: String"))
// The original kebab-case name is what GObject knows the property by.
#expect(source.contains("g_object_get_property(_instancePointer(pointer), \"source-property\", &gvalue)"))
}
// Skips and filtering
@Test("A property whose type has no GValue support is skipped, not rendered")
func unsupportedPropertyTypeIsSkipped() throws {
// Interface-typed properties have no GValueOps mapping.
let prop = Property(name: "plugin",
type: .typeRef("TypePlugin", namespace: "GObject"),
isReadable: true, isWritable: false)
let (source, plan, skips) = renderClass(named: "Loader", properties: [prop])
#expect(plan.properties.isEmpty)
#expect(skips.count == 1)
#expect(skips[0].reason == .unsupportedGValueCategory)
#expect(!source.contains("var plugin"))
}
@Test("Non-bindable properties are excluded entirely")
func nonBindablePropertyIsExcluded() throws {
let prop = Property(name: "internalThing", type: .int32,
isReadable: true, isWritable: false,
symbolInfo: SymbolInfo(isIntrospectable: false))
let (source, plan, skips) = renderClass(named: "Widget", properties: [prop])
// Not bindable => never even considered: no plan entry, no skip.
#expect(plan.properties.isEmpty)
#expect(skips.isEmpty)
#expect(!source.contains("internalThing"))
}
// Interface property extension defaults
@Test("A readable-only interface property becomes a GValue-backed extension default getter")
func interfaceReadOnlyPropertyIsExtensionDefault() throws {
let iface = Interface(
name: "Orientable", cType: "GtkOrientable",
properties: [
Property(name: "orientation",
type: .typeRef("Orientation", namespace: "GObject"),
isReadable: true, isWritable: false),
]
)
let (plan, skips) = planInterface(iface, context: makeContext())
#expect(skips.isEmpty)
#expect(plan.properties.count == 1)
let module = ModulePlan(module: "GObject", types: [.interface(plan)], skips: [],
coverage: CoverageStats())
let source = renderModule(module)["Orientable.swift"] ?? ""
// Protocol body is bare only the `pointer` requirement.
#expect(!source.contains("var orientation: Orientation { get }"))
// A default implementation, in a protocol extension, with a real body.
#expect(source.contains("extension Orientable {"))
#expect(source.contains("public var orientation: Orientation"))
#expect(source.contains("g_object_get_property"))
}
@Test("A writable interface property becomes a GValue-backed extension default getter+setter")
func interfaceWritablePropertyIsExtensionDefault() throws {
let iface = Interface(
name: "Editable", cType: "GtkEditable",
properties: [
Property(name: "text", type: .string, isReadable: true, isWritable: true),
]
)
let (plan, _) = planInterface(iface, context: makeContext())
let module = ModulePlan(module: "GObject", types: [.interface(plan)], skips: [],
coverage: CoverageStats())
let source = renderModule(module)["Editable.swift"] ?? ""
#expect(!source.contains("var text: String { get set }"))
#expect(source.contains("extension Editable {"))
#expect(source.contains("public var text: String"))
#expect(source.contains("g_object_set_property"))
}
// getter=/setter= method delegation
@Test("A read+write property with getter=/setter= delegates to those methods")
func readWriteDelegatesToAccessorMethods() throws {
let prop = Property(name: "label", type: .string, isReadable: true, isWritable: true,
getter: "get_label", setter: "set_label")
let (source, plan, _) = renderClass(
named: "Button", properties: [prop],
methods: [getterMethod("get_label", returns: .string),
setterMethod("set_label", arg: "label", of: .string)])
// The accessor forwards to the generated methods; no GValue machinery.
#expect(plan.properties[0].getter == .delegate(method: "getLabel", argumentLabel: nil))
#expect(plan.properties[0].setter == .delegate(method: "setLabel", argumentLabel: "label"))
#expect(source.contains("public var label: String {"))
#expect(source.contains("getLabel()"))
#expect(source.contains("setLabel(label: newValue)"))
#expect(!source.contains("g_object_get_property"))
#expect(!source.contains("g_object_set_property"))
}
@Test("A read-only property with getter= delegates to the getter method")
func readOnlyDelegatesToGetterMethod() throws {
let prop = Property(name: "count", type: .int32, isReadable: true, isWritable: false,
getter: "get_count")
let (source, plan, _) = renderClass(
named: "Model", properties: [prop],
methods: [getterMethod("get_count", returns: .int32)])
#expect(plan.properties[0].getter == .delegate(method: "getCount", argumentLabel: nil))
#expect(plan.properties[0].setter == nil)
#expect(source.contains("public var count: Int32"))
#expect(source.contains("getCount()"))
#expect(!source.contains("g_object_get_property"))
}
@Test("A delegated getter inherits the method's nullability")
func delegatedGetterInheritsNullability() throws {
// The getter method returns Object? (nullable) the property type must
// follow it, which the uniform GValue path (non-optional Object) would
// get wrong and could trap.
let prop = Property(name: "source",
type: .typeRef("Object", namespace: "GObject"),
isReadable: true, isWritable: false, getter: "get_source")
let (source, plan, _) = renderClass(
named: "Binding", properties: [prop],
methods: [getterMethod("get_source",
returns: .typeRef("Object", namespace: "GObject"),
nullable: true)])
#expect(plan.properties[0].swiftType == "Object?")
#expect(source.contains("public var source: Object?"))
#expect(source.contains("getSource()"))
}
@Test("Falls back to GValue when the named getter= method is unavailable")
func fallsBackToGValueWhenMethodMissing() throws {
// getter= names a method that was never planned (not in the class), so
// the accessor must fall back to the GValue machinery.
let prop = Property(name: "orientation",
type: .typeRef("Orientation", namespace: "GObject"),
isReadable: true, isWritable: false, getter: "get_orientation")
let (source, plan, _) = renderClass(named: "Orientable", properties: [prop], methods: [])
if case .gvalue = plan.properties[0].getter {} else {
Issue.record("expected GValue fallback, got \(plan.properties[0].getter)")
}
#expect(source.contains("g_value_init(&gvalue, gTypeEnum)"))
#expect(source.contains("numericCast(g_value_get_enum(&gvalue))"))
}
@Test("Falls back to GValue when delegated getter and setter types disagree")
func fallsBackToGValueOnTypeMismatch() throws {
// Nullable getter (String?) but non-null setter (String): delegating
// both would give the computed property two different types, so the
// planner uses GValue for both instead.
let prop = Property(name: "text", type: .string, isReadable: true, isWritable: true,
getter: "get_text", setter: "set_text")
let (source, plan, _) = renderClass(
named: "Entry", properties: [prop],
methods: [getterMethod("get_text", returns: .string, nullable: true),
setterMethod("set_text", arg: "text", of: .string, nullable: false)])
if case .gvalue = plan.properties[0].getter {} else {
Issue.record("expected GValue fallback on type mismatch")
}
#expect(source.contains("g_object_get_property"))
#expect(source.contains("g_object_set_property"))
}
// C6 regression: g_value_unset in setter + boxed pointer + nullable string
@Test("Getter always emits g_value_unset")
func getterEmitsGValueUnset() throws {
let prop = Property(name: "length", type: .int32, isReadable: true, isWritable: false)
let (source, _, _) = renderClass(named: "Buffer", properties: [prop])
// The getter body ends with g_value_unset before return.
#expect(source.contains("g_value_unset"))
}
@Test("Writable string property setter emits g_value_unset after g_object_set_property")
func setterEmitsGValueUnset() throws {
let prop = Property(name: "label", type: .string, isReadable: true, isWritable: true)
let (source, _, _) = renderClass(named: "Button", properties: [prop])
// Each setter call should be followed by a g_value_unset.
#expect(source.contains("g_value_unset"))
// (This test is specifically for the setter path the getter already had it.)
}
@Test("Boxed GValue setter passes .pointer, not the wrapper")
func boxedSetterUsesPointer() throws {
// Use a Value (boxed) type for a writable property.
let prop = Property(name: "data",
type: .typeRef("Value", namespace: "GObject"),
isReadable: true, isWritable: true)
let (source, _, _) = renderClass(named: "Holder", properties: [prop])
#expect(source.contains("g_value_set_boxed(&gvalue, newValue.pointer)"))
#expect(!source.contains("g_value_set_boxed(&gvalue, newValue)"))
}
@Test("Nullable string getter uses .map to handle NULL")
func nullableStringGetterUsesMap() throws {
let prop = Property(name: "title", type: .string, isReadable: true, isWritable: false,
isNullable: true)
let (source, plan, _) = renderClass(named: "Window", properties: [prop])
#expect(plan.properties[0].swiftType == "String?")
#expect(source.contains("g_value_get_string(&gvalue).map { String(cString: $0) }"))
}
// MARK: - E2: property/method name collision (Pango.FontFamily.isVariable)
@Test("A property whose Swift name collides with a planned method is dropped, keeping the method")
func propertyCollidingWithMethodIsSkipped() throws {
// GIR `is-variable` property and `is_variable` method both yield the
// Swift name `isVariable` `var isVariable: Bool` and
// `func isVariable() -> Bool` are an invalid redeclaration.
let prop = Property(name: "is-variable", type: .boolean, isReadable: true, isWritable: false)
let method = getterMethod("is_variable", returns: .boolean)
let (source, plan, skips) = renderClass(named: "FontFamily", properties: [prop], methods: [method])
#expect(plan.properties.isEmpty)
#expect(plan.methods.contains { $0.name == "isVariable" })
#expect(skips.contains { $0.reason == .nameCollision })
#expect(!source.contains("public var isVariable"))
#expect(source.contains("func isVariable("))
}
}