// 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_` 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_` 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(")) } }