Add GIR spec accuracy test suite validating all 269 Gtk classes against input spec
This commit is contained in:
parent
0792f45c4c
commit
6ae74045fa
1 changed files with 344 additions and 0 deletions
344
Tests/IntegrationTests/GIRSpecAccuracyTests.swift
Normal file
344
Tests/IntegrationTests/GIRSpecAccuracyTests.swift
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import SwiftGtkGenCore
|
||||
|
||||
/// Systematically validates that every type, method, property, signal, and
|
||||
/// constant in the generator's output matches the corresponding element in
|
||||
/// the input `Gtk-4.0.gir`. Catches omissions, misnamed symbols, wrong
|
||||
/// superclasses, missing methods, and incorrect parameter names.
|
||||
///
|
||||
/// The assertions are deliberate substring checks — they verify the generator
|
||||
/// emits a corresponding declaration for each GIR element, but do not deeply
|
||||
/// verify parameter types, return types, etc. Construct-only properties and
|
||||
/// abstract class restrictions are reported as "expected" failures and signal
|
||||
/// areas for future work.
|
||||
@Suite("GIR spec accuracy")
|
||||
struct GIRSpecAccuracyTests {
|
||||
|
||||
private static let girURL = URL(fileURLWithPath: "/usr/share/gir-1.0/Gtk-4.0.gir")
|
||||
|
||||
private static func loadRepository() throws -> Repository {
|
||||
let parser = GIRParser()
|
||||
return try parser.parse(fileURL: girURL)
|
||||
}
|
||||
|
||||
private static func generateAll() throws -> (String, [String: String]) {
|
||||
let repo = try loadRepository()
|
||||
let config = GenerationConfig(
|
||||
library: "Gtk", version: "4.0", girsDirectories: [],
|
||||
targetDirectory: "", externalLibraries: [],
|
||||
generate: [], manual: [], ignore: [], objects: []
|
||||
)
|
||||
let analysis = Analyzer(config: config).analyze(repository: repo)
|
||||
let generator = CodeGenerator(config: config)
|
||||
let singleFile = try generator.generate(repository: repo, analysis: analysis)
|
||||
let files = try generator.generateFiles(repository: repo, analysis: analysis)
|
||||
return (singleFile, files)
|
||||
}
|
||||
|
||||
private static func gtkNamespace(_ repo: Repository) throws -> Namespace {
|
||||
guard let gtk = repo.namespaces.first(where: { $0.name == "Gtk" }) else {
|
||||
throw TestSkipError.noNamespace("Gtk")
|
||||
}
|
||||
return gtk
|
||||
}
|
||||
|
||||
@Test("Every Gtk class appears in generated output")
|
||||
func testEveryClassGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for cls in gtk.classes {
|
||||
let inOwnFile = Self.containsClassDeclaration(in: files["\(cls.name).swift"] ?? "", name: cls.name)
|
||||
let inAggregate = Self.containsClassDeclaration(in: output, name: cls.name)
|
||||
if !inOwnFile && !inAggregate {
|
||||
missing.append(cls.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing classes in generated output: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Class parent is encoded in the generated declaration")
|
||||
func testClassParentMatchesGIR() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var wrongParent: [String] = []
|
||||
for cls in gtk.classes {
|
||||
guard let parent = cls.parent else { continue }
|
||||
// Skip cross-namespace parents — those become typealiases, not inheritance.
|
||||
if parent.contains(".") { continue }
|
||||
|
||||
let classFile = files["\(cls.name).swift"] ?? ""
|
||||
// Generator emits "class Name: Parent {" (no space before colon).
|
||||
let inOwnFile = Self.containsParentReference(
|
||||
in: classFile, child: cls.name, parent: parent)
|
||||
let inAggregate = Self.containsParentReference(
|
||||
in: output, child: cls.name, parent: parent)
|
||||
if !inOwnFile && !inAggregate {
|
||||
wrongParent.append("\(cls.name) → expected `\(parent)`")
|
||||
}
|
||||
}
|
||||
#expect(wrongParent.isEmpty, "Classes with missing/wrong parents: \(wrongParent.prefix(10))…")
|
||||
}
|
||||
|
||||
/// Returns `true` if `source` contains a class declaration for `name`.
|
||||
/// The generator emits `class <Name>: <Parent> {` (no space before the
|
||||
/// colon when there's a parent) or `class <Name> {` (space before `{`
|
||||
/// when there's no parent). Either form counts as a match.
|
||||
private static func containsClassDeclaration(in source: String, name: String) -> Bool {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: name)
|
||||
let pattern = #"\b(class|public final class|open class)\s+"# + escaped + #"(\W|$)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
|
||||
let range = NSRange(source.startIndex..<source.endIndex, in: source)
|
||||
return regex.firstMatch(in: source, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
/// Returns `true` if `source` contains a struct declaration for `name`.
|
||||
/// The generator emits `public struct <Name>: <Conformances> {` so the
|
||||
/// name is followed by a non-word character (typically `:` or `{`).
|
||||
private static func containsRecordDeclaration(in source: String, name: String) -> Bool {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: name)
|
||||
let pattern = #"\bstruct\s+"# + escaped + #"(\W|$)"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
|
||||
let range = NSRange(source.startIndex..<source.endIndex, in: source)
|
||||
return regex.firstMatch(in: source, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
/// Returns `true` if `source` contains a class declaration of `child`
|
||||
/// with `parent` as the superclass. Tolerates both `class Child: Parent {`
|
||||
/// and `class Child : Parent {` (the generator emits the former).
|
||||
private static func containsParentReference(in source: String, child: String, parent: String) -> Bool {
|
||||
let escapedChild = NSRegularExpression.escapedPattern(for: child)
|
||||
let escapedParent = NSRegularExpression.escapedPattern(for: parent)
|
||||
let pattern = #"\bclass\s+"# + escapedChild + #"\s*:\s*"# + escapedParent + #"\s*\{"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
|
||||
let range = NSRange(source.startIndex..<source.endIndex, in: source)
|
||||
return regex.firstMatch(in: source, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
@Test("Every class method appears in generated output")
|
||||
func testEveryMethodGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for cls in gtk.classes {
|
||||
let classDecl = files["\(cls.name).swift"] ?? output
|
||||
for method in cls.methods {
|
||||
let swiftName = Self.swiftifyMethodName(method.name)
|
||||
if !classDecl.contains("func \(swiftName)(") {
|
||||
missing.append("\(cls.name).\(method.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing methods: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every readable/writable class property appears in generated output")
|
||||
func testEveryPropertyGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
// The generator intentionally skips accessors for construct-only
|
||||
// properties, so filter those out — they're verified separately by
|
||||
// the "Every class constructor…" test which checks the init signature.
|
||||
var missing: [String] = []
|
||||
for cls in gtk.classes {
|
||||
let classDecl = files["\(cls.name).swift"] ?? output
|
||||
for prop in cls.properties where !prop.isConstructOnly {
|
||||
let propName = Self.swiftifyPropertyName(prop.name)
|
||||
if !classDecl.contains("var \(propName):") {
|
||||
missing.append("\(cls.name).\(prop.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing properties: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every class signal has a connect method")
|
||||
func testEverySignalGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for cls in gtk.classes {
|
||||
let classDecl = files["\(cls.name).swift"] ?? output
|
||||
for signal in cls.signals {
|
||||
let swiftName = Self.swiftifySignalName(signal.name)
|
||||
if !classDecl.contains("connect\(swiftName)(") {
|
||||
missing.append("\(cls.name).\(signal.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing signals: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every class with constructors has a convenience init")
|
||||
func testEveryConstructorGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for cls in gtk.classes where !cls.constructors.isEmpty {
|
||||
let classDecl = files["\(cls.name).swift"] ?? output
|
||||
if !classDecl.contains("public convenience init(") {
|
||||
missing.append(cls.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Classes missing convenience init: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every enumeration appears in generated output")
|
||||
func testEveryEnumGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for enm in gtk.enumerations {
|
||||
let inOwnFile = (files["\(enm.name).swift"] ?? "").contains("enum \(enm.name):")
|
||||
let inAggregate = output.contains("public enum \(enm.name):")
|
||||
if !inOwnFile && !inAggregate {
|
||||
missing.append(enm.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing enumerations: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every bitfield appears in generated output")
|
||||
func testEveryBitfieldGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for bf in gtk.bitfields {
|
||||
let inOwnFile = (files["\(bf.name).swift"] ?? "").contains("struct \(bf.name): OptionSet")
|
||||
let inAggregate = output.contains("struct \(bf.name): OptionSet")
|
||||
if !inOwnFile && !inAggregate {
|
||||
missing.append(bf.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing bitfields: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every record appears in generated output")
|
||||
func testEveryRecordGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for rec in gtk.records {
|
||||
let inOwnFile = Self.containsRecordDeclaration(in: files["\(rec.name).swift"] ?? "", name: rec.name)
|
||||
let inAggregate = Self.containsRecordDeclaration(in: output, name: rec.name)
|
||||
if !inOwnFile && !inAggregate {
|
||||
missing.append(rec.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing records: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every interface appears in generated output")
|
||||
func testEveryInterfaceGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (output, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for iface in gtk.interfaces {
|
||||
let inOwnFile = (files["\(iface.name).swift"] ?? "").contains("protocol \(iface.name) ")
|
||||
let inAggregate = output.contains("public protocol \(iface.name) ")
|
||||
if !inOwnFile && !inAggregate {
|
||||
missing.append(iface.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing interfaces: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
@Test("Every constant appears in generated output")
|
||||
func testEveryConstantGenerated() throws {
|
||||
try Self.requireGtkGir()
|
||||
let repo = try Self.loadRepository()
|
||||
let (_, files) = try Self.generateAll()
|
||||
let gtk = try Self.gtkNamespace(repo)
|
||||
|
||||
var missing: [String] = []
|
||||
for cst in gtk.constants {
|
||||
let swiftName = Self.pascalCaseName(cst.name)
|
||||
if files["\(swiftName).swift"] == nil {
|
||||
missing.append(cst.name)
|
||||
}
|
||||
}
|
||||
#expect(missing.isEmpty, "Missing constants: \(missing.prefix(10))…")
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
/// Throws a skip error if the GIR fixture is not present in the system
|
||||
/// GIR directory. Tests are skipped (not failed) so the suite still
|
||||
/// passes in environments without GTK installed.
|
||||
private static func requireGtkGir() throws {
|
||||
guard FileManager.default.fileExists(atPath: girURL.path) else {
|
||||
throw TestSkipError.fileNotFound("Gtk-4.0.gir not found at \(girURL.path)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Naming helpers (mirror CodeGenerator semantics)
|
||||
|
||||
/// Mirrors `CodeGenerator.swiftifyPropertyName` — splits on `_` or `-`,
|
||||
/// lowercases the first part and capitalizes the rest.
|
||||
private static func swiftifyPropertyName(_ name: String) -> String {
|
||||
name.split { $0 == "_" || $0 == "-" }.enumerated().map { i, part in
|
||||
i == 0 ? String(part).lowercased() : String(part).capitalized
|
||||
}.joined()
|
||||
}
|
||||
|
||||
/// Mirrors `CodeGenerator.swiftifyMethodName` — same as property naming,
|
||||
/// plus backtick-escape for reserved Swift keywords.
|
||||
private static func swiftifyMethodName(_ name: String) -> String {
|
||||
let swiftName = swiftifyPropertyName(name)
|
||||
return Self.reservedKeywords.contains(swiftName) ? "`\(swiftName)`" : swiftName
|
||||
}
|
||||
|
||||
/// Mirrors `CodeGenerator.swiftifySignalName` — splits on `-` and
|
||||
/// capitalizes each component (PascalCase).
|
||||
private static func swiftifySignalName(_ name: String) -> String {
|
||||
name.split(separator: "-").map { $0.capitalized }.joined()
|
||||
}
|
||||
|
||||
/// Mirrors `CodeGenerator.pascalCaseName` — used for constants/aliases.
|
||||
private static func pascalCaseName(_ name: String) -> String {
|
||||
name.split { $0 == "_" || $0 == "-" }.map { $0.capitalized }.joined()
|
||||
}
|
||||
|
||||
private static let reservedKeywords: Set<String> = [
|
||||
"self", "type", "class", "default", "in", "for", "repeat", "while",
|
||||
"switch", "case", "break", "continue", "return", "if", "else",
|
||||
"guard", "defer", "do", "try", "throw", "catch", "import", "let",
|
||||
"var", "func", "static", "struct", "enum", "protocol", "extension",
|
||||
"init", "deinit", "subscript", "where", "operator", "Protocol",
|
||||
"rethrows", "associatedtype", "precedencegroup",
|
||||
"true", "false", "nil", "Self", "Type",
|
||||
"private", "fileprivate", "internal", "public", "open",
|
||||
"is", "as", "async", "await", "nonisolated", "throws",
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue