// RecordGenerationTests.swift // Covers Phase C4: boxed-record memory management. A boxed wrapper owns its // pointer, so it needs a `deinit` that frees it and an `init(retaining:)` that // copies a borrowed pointer. The free/copy functions are resolved from the GIR // `free-function`/`copy-function` attributes when present, else from the // record's own `unref`/`free` and `ref`/`copy` methods. These tests lock in // that resolution and the rendered surface (`isolated deinit`, the copy init). import Testing @testable import GObjectGeneratorCore @Suite("Record generation") struct RecordGenerationTests { func makeContext() -> MapContext { let glib = Repository(namespaces: [Namespace(name: "GLib", version: "2.0")]) let registry = TypeRegistry(repositories: ["GLib": glib]) return MapContext(registry: registry, currentModule: "GLib", currentNamespace: "GLib") } /// Renders a single planned record to Swift source via the module renderer. func render(_ plan: RecordPlan) -> String { let module = ModulePlan(module: "GLib", types: [.record(plan)], skips: [], coverage: CoverageStats()) return renderModule(module)["\(plan.name).swift"] ?? "" } /// A record method taking only its instance parameter (a canonical /// `ref`/`unref`/`copy`/`free` shape). func releaseMethod(_ name: String, _ cIdentifier: String) -> Method { Method(name: name, cIdentifier: cIdentifier, parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true)]) } @Test("Explicit free/copy attributes take priority over method scan") func explicitAttributesWin() { let record = Record( name: "Bytes", cType: "GBytes", getTypeFunction: "g_bytes_get_type", copyFunction: "g_bytes_ref", freeFunction: "g_bytes_unref", methods: [releaseMethod("ref", "g_bytes_ref_method"), releaseMethod("unref", "g_bytes_unref_method")] ) #expect(resolvedFreeFunction(record) == "g_bytes_unref") #expect(resolvedCopyFunction(record) == "g_bytes_ref") } @Test("Falls back to the record's own unref/ref methods") func methodScanFallback() { // No explicit attributes — resolve from methods. `unref`/`ref` are // preferred over `free`/`copy` when both exist. let record = Record( name: "TimeZone", cType: "GTimeZone", getTypeFunction: "g_time_zone_get_type", methods: [releaseMethod("copy", "g_time_zone_copy"), releaseMethod("free", "g_time_zone_free"), releaseMethod("ref", "g_time_zone_ref"), releaseMethod("unref", "g_time_zone_unref")] ) #expect(resolvedFreeFunction(record) == "g_time_zone_unref") #expect(resolvedCopyFunction(record) == "g_time_zone_ref") } @Test("A method taking extra arguments is not treated as a destructor") func destructorMustBeParameterless() { let record = Record( name: "Weird", cType: "GWeird", getTypeFunction: "g_weird_get_type", methods: [Method(name: "free", cIdentifier: "g_weird_free", parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true), Parameter(name: "flags", type: .int32)])] ) #expect(resolvedFreeFunction(record) == nil) } @Test("Boxed record renders init(retaining:) and an isolated deinit") func rendersMemoryManagement() { let plan = RecordPlan( name: "VariantType", cType: "GVariantType", getTypeFunction: "g_variant_type_get_gtype", copyFunction: "g_variant_type_copy", freeFunction: "g_variant_type_free" ) let source = render(plan) #expect(source.contains("@_spi(SGTKInternal) public let pointer: UnsafeMutableRawPointer")) #expect(source.contains("@_spi(SGTKInternal) public init(takingOwnership pointer: UnsafeMutableRawPointer)")) #expect(source.contains("@_spi(SGTKInternal) public init(retaining pointer: UnsafeMutableRawPointer)")) #expect(source.contains("g_variant_type_copy(_instancePointer(pointer))")) #expect(source.contains("isolated deinit {")) #expect(source.contains("g_variant_type_free(_instancePointer(pointer))")) // Records with a free function claim to take responsibility for freeing. #expect(source.contains("takes responsibility")) #expect(!source.contains("never frees")) } @Test("A record with no resolvable free renders no deinit") func noFreeNoDeinit() { let plan = RecordPlan(name: "PollFD", cType: "GPollFD") let source = render(plan) #expect(!source.contains("deinit")) #expect(!source.contains("retaining")) // The takingOwnership init is always present. #expect(source.contains("takingOwnership")) // No-free records must not claim to free the pointee. #expect(source.contains("never frees")) #expect(!source.contains("takes responsibility")) } // MARK: - C4 copy/free pairing @Test("copy+unref without free+ref is rejected as crossed pairing") func crossedCopyUnrefPairing() { // Record has `copy` but `unref` (no `free` or `ref`). // The old independent-scan would pair copy with unref (wrong). // The new paired resolver should return (nil, nil) because the categories // are inconsistent — copy+free (copy semantics) nor ref+unref (refcount) // neither is fully present. let record = Record( name: "WeirdBuf", cType: "GWeirdBuf", getTypeFunction: "g_weird_buf_get_type", methods: [ releaseMethod("copy", "g_weird_buf_copy"), releaseMethod("unref", "g_weird_buf_unref"), ] ) #expect(resolvedFreeFunction(record) == nil) #expect(resolvedCopyFunction(record) == nil) } @Test("copy+free together are paired correctly") func pairedCopyFree() { let record = Record( name: "CopyFree", cType: "GCopyFree", getTypeFunction: "g_copy_free_get_type", methods: [ releaseMethod("copy", "g_copy_free_copy"), releaseMethod("free", "g_copy_free_free"), ] ) #expect(resolvedFreeFunction(record) == "g_copy_free_free") #expect(resolvedCopyFunction(record) == "g_copy_free_copy") } @Test("ref+unref together (refcount) take priority over copy+free") func refcountTakesPriority() { let record = Record( name: "Priority", cType: "GPriority", getTypeFunction: "g_priority_get_type", methods: [ releaseMethod("ref", "g_priority_ref"), releaseMethod("unref", "g_priority_unref"), releaseMethod("copy", "g_priority_copy"), releaseMethod("free", "g_priority_free"), ] ) // refcount pair wins over copy/free. #expect(resolvedFreeFunction(record) == "g_priority_unref") #expect(resolvedCopyFunction(record) == "g_priority_ref") } @Test("Boxed record renders callables but hides lifetime methods") func rendersRecordCallablesWithoutLifetimeMethods() { let record = Record( name: "BreakpointCondition", cType: "AdwBreakpointCondition", getTypeFunction: "adw_breakpoint_condition_get_type", copyFunction: "adw_breakpoint_condition_copy", freeFunction: "adw_breakpoint_condition_free", methods: [ releaseMethod("free", "adw_breakpoint_condition_free"), releaseMethod("unref", "adw_breakpoint_condition_unref"), Method(name: "to_string", cIdentifier: "adw_breakpoint_condition_to_string", parameters: [Parameter(name: "self", type: .pointer, isInstanceParameter: true)], returnValue: ReturnValue(type: .string)), ], constructors: [ Constructor(name: "new_length", cIdentifier: "adw_breakpoint_condition_new_length", parameters: [Parameter(name: "value", type: .double)]), Constructor(name: "new_and", cIdentifier: "adw_breakpoint_condition_new_and", parameters: [Parameter(name: "condition1", type: .double), Parameter(name: "condition2", type: .double)]), Constructor(name: "new_or", cIdentifier: "adw_breakpoint_condition_new_or", parameters: [Parameter(name: "condition1", type: .double), Parameter(name: "condition2", type: .double)]), ], functions: [GlobalFunction(name: "parse", cIdentifier: "adw_breakpoint_condition_parse", parameters: [Parameter(name: "value", type: .string, cType: "const char*")])] ) let result = planRecord(record, context: makeContext()) let source = render(result.plan) #expect(result.memberSkips.isEmpty) #expect(source.contains("public convenience init(")) #expect(source.contains("_rawPointer(adw_breakpoint_condition_new_length(")) #expect(source.contains("condition1: Double, condition2: Double")) #expect(source.contains("orCondition1: Double, orCondition2: Double")) #expect(source.contains("public func toString(")) #expect(source.contains("public static func parse(")) #expect(!source.contains("public func free(")) #expect(!source.contains("public func unref(")) } @Test("moved-to namespace functions render on their record") func routesMovedToFunction() { let record = Record(name: "MyRecord", cType: "MyRecord", getTypeFunction: "my_record_get_type") let moved = GlobalFunction( name: "my_record_do_thing", cIdentifier: "my_record_do_thing", returnValue: ReturnValue(type: .int32), symbolInfo: SymbolInfo(movedTo: "MyRecord.do_thing")) let namespace = Namespace(name: "Test", version: "1.0", records: [record], functions: [moved]) let repository = Repository(namespaces: [namespace]) let analysis = MultiPackageAnalysis( repositories: ["Test": repository], directDependencies: ["Test": []], transitiveDependencies: ["Test": []], implicitImports: ["Test": []], packageConfigs: [:]) let registry = TypeRegistry(repositories: ["Test": repository]) let module = planModules(analysis: analysis, registry: registry)["Test"]! let source = renderModule(module).values.joined(separator: "\n") #expect(source.contains("public static func doThing(")) #expect(!source.contains("public func myRecordDoThing(")) } }