From 6ae74045fa3b3818c9a7e3fa55f41ae0ef6d1013 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Wed, 1 Jul 2026 19:14:25 -0400 Subject: [PATCH] Add GIR spec accuracy test suite validating all 269 Gtk classes against input spec --- .../GIRSpecAccuracyTests.swift | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 Tests/IntegrationTests/GIRSpecAccuracyTests.swift diff --git a/Tests/IntegrationTests/GIRSpecAccuracyTests.swift b/Tests/IntegrationTests/GIRSpecAccuracyTests.swift new file mode 100644 index 0000000..18bff38 --- /dev/null +++ b/Tests/IntegrationTests/GIRSpecAccuracyTests.swift @@ -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 : {` (no space before the + /// colon when there's a parent) or `class {` (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..: {` 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.. 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.. 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 = [ + "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", + ] +}