213 lines
11 KiB
Swift
213 lines
11 KiB
Swift
// 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("))
|
|
}
|
|
}
|