From d9057eb2cc7c57a6f554cc8e67d5c09feb9a9cc4 Mon Sep 17 00:00:00 2001 From: Max Rozdobudko Date: Sat, 28 Feb 2026 13:58:20 +0200 Subject: [PATCH] test: verify generated code compiles across Swift language modes Add compilation verification targets that build generated code under 4 combinations of Swift language mode (5/6) and default actor isolation (nonisolated/MainActor). A build tool plugin generates fixture files at build time from theme.json, replacing previously committed generated files. - Add --skip-defaults flag to ThemeKitGeneratorCLI - Add internal GenerateTestFixturesPlugin (BuildToolPlugin) - Add 4 fixture targets with theme.json + hand-written Theme+Defaults.swift - Add GeneratedCodeCompilationTests with runtime smoke checks Co-Authored-By: Claude Opus 4.6 --- Package.swift | 44 ++++++++++ .../GenerateTestFixturesPlugin.swift | 80 +++++++++++++++++++ Sources/ThemeKitGeneratorCLI/main.swift | 11 ++- .../GeneratedCodeCompilationTests.swift | 47 +++++++++++ .../GeneratedCodeSwift5/Theme+Defaults.swift | 57 +++++++++++++ Tests/GeneratedCodeSwift5/theme.json | 12 +++ .../Theme+Defaults.swift | 57 +++++++++++++ Tests/GeneratedCodeSwift5MainActor/theme.json | 12 +++ .../GeneratedCodeSwift6/Theme+Defaults.swift | 57 +++++++++++++ Tests/GeneratedCodeSwift6/theme.json | 12 +++ .../Theme+Defaults.swift | 57 +++++++++++++ Tests/GeneratedCodeSwift6MainActor/theme.json | 12 +++ 12 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift create mode 100644 Tests/GeneratedCodeCompilationTests/GeneratedCodeCompilationTests.swift create mode 100644 Tests/GeneratedCodeSwift5/Theme+Defaults.swift create mode 100644 Tests/GeneratedCodeSwift5/theme.json create mode 100644 Tests/GeneratedCodeSwift5MainActor/Theme+Defaults.swift create mode 100644 Tests/GeneratedCodeSwift5MainActor/theme.json create mode 100644 Tests/GeneratedCodeSwift6/Theme+Defaults.swift create mode 100644 Tests/GeneratedCodeSwift6/theme.json create mode 100644 Tests/GeneratedCodeSwift6MainActor/Theme+Defaults.swift create mode 100644 Tests/GeneratedCodeSwift6MainActor/theme.json diff --git a/Package.swift b/Package.swift index 7f89f2d..097a67b 100644 --- a/Package.swift +++ b/Package.swift @@ -50,5 +50,49 @@ let package = Package( name: "ThemeKitGeneratorTests", dependencies: ["ThemeKitGenerator"] ), + .plugin( + name: "GenerateTestFixturesPlugin", + capability: .buildTool(), + dependencies: ["ThemeKitGeneratorCLI"], + path: "Plugins/GenerateTestFixturesPlugin" + ), + // Generated code compilation verification targets + .target( + name: "GeneratedCodeSwift5", + dependencies: ["ThemeKit"], + path: "Tests/GeneratedCodeSwift5", + swiftSettings: [.swiftLanguageMode(.v5)], + plugins: [.plugin(name: "GenerateTestFixturesPlugin")] + ), + .target( + name: "GeneratedCodeSwift5MainActor", + dependencies: ["ThemeKit"], + path: "Tests/GeneratedCodeSwift5MainActor", + swiftSettings: [.swiftLanguageMode(.v5), .defaultIsolation(MainActor.self)], + plugins: [.plugin(name: "GenerateTestFixturesPlugin")] + ), + .target( + name: "GeneratedCodeSwift6", + dependencies: ["ThemeKit"], + path: "Tests/GeneratedCodeSwift6", + swiftSettings: [.swiftLanguageMode(.v6)], + plugins: [.plugin(name: "GenerateTestFixturesPlugin")] + ), + .target( + name: "GeneratedCodeSwift6MainActor", + dependencies: ["ThemeKit"], + path: "Tests/GeneratedCodeSwift6MainActor", + swiftSettings: [.swiftLanguageMode(.v6), .defaultIsolation(MainActor.self)], + plugins: [.plugin(name: "GenerateTestFixturesPlugin")] + ), + .testTarget( + name: "GeneratedCodeCompilationTests", + dependencies: [ + "GeneratedCodeSwift5", + "GeneratedCodeSwift5MainActor", + "GeneratedCodeSwift6", + "GeneratedCodeSwift6MainActor", + ] + ), ] ) diff --git a/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift b/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift new file mode 100644 index 0000000..f099069 --- /dev/null +++ b/Plugins/GenerateTestFixturesPlugin/GenerateTestFixturesPlugin.swift @@ -0,0 +1,80 @@ +import PackagePlugin +import Foundation + +@main +struct GenerateTestFixturesPlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + guard let sourceModule = target as? SourceModuleTarget else { return [] } + + let configFile = sourceModule.sourceFiles.first { $0.url.lastPathComponent == "theme.json" } + guard let configFile else { + Diagnostics.error("No theme.json found in target \(target.name)") + return [] + } + + let tool = try context.tool(named: "ThemeKitGeneratorCLI") + let outputDir = context.pluginWorkDirectoryURL + let outputFiles = try computeOutputFiles(configURL: configFile.url, outputDir: outputDir) + + return [ + .buildCommand( + displayName: "Generate ThemeKit fixtures for \(target.name)", + executable: tool.url, + arguments: [ + "--config", configFile.url.path(), + "--output", outputDir.path(), + "--skip-defaults", + ], + inputFiles: [configFile.url], + outputFiles: outputFiles + ), + ] + } + + /// Parses theme.json minimally to compute the list of files the generator will produce. + private func computeOutputFiles(configURL: URL, outputDir: URL) throws -> [URL] { + let data = try Data(contentsOf: configURL) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + let styles = json["styles"] as? [String: Any] ?? [:] + let config = json["config"] as? [String: Any] ?? [:] + + let categoryKeys = styles.keys + let shouldGeneratePreview = config["shouldGeneratePreview"] as? Bool ?? false + + // Map JSON category keys to struct names + let categoryStructNames: [String: String] = [ + "colors": "ThemeColors", + "gradients": "ThemeGradients", + "meshGradients": "ThemeMeshGradients", + "shadows": "ThemeShadows", + ] + + var files: [String] = [] + + // Static files (always generated) + files.append("ThemeShapeStyle.swift") + files.append("Environment+Theme.swift") + files.append("Theme.swift") + files.append("Theme+CopyWith.swift") + + // Conditional: ThemeShadowedStyle only when shadows present + if categoryKeys.contains("shadows") { + files.append("ThemeShadowedStyle.swift") + } + + // Per-category files + for key in categoryKeys { + guard let structName = categoryStructNames[key] else { continue } + files.append("\(structName).swift") + files.append("\(structName)+CopyWith.swift") + files.append("ShapeStyle+\(structName).swift") + } + + // Optional preview file + if shouldGeneratePreview { + files.append("Theme+Preview.swift") + } + + return files.map { outputDir.appendingPathComponent($0) } + } +} diff --git a/Sources/ThemeKitGeneratorCLI/main.swift b/Sources/ThemeKitGeneratorCLI/main.swift index ad92cc9..5e3e68c 100644 --- a/Sources/ThemeKitGeneratorCLI/main.swift +++ b/Sources/ThemeKitGeneratorCLI/main.swift @@ -6,6 +6,7 @@ func run() throws { var configPath = "theme.json" var outputPath = "." + var skipDefaults = false var i = 1 while i < args.count { @@ -22,6 +23,8 @@ func run() throws { throw GeneratorError.missingArgument("--output") } outputPath = args[i] + case "--skip-defaults": + skipDefaults = true default: break } @@ -42,13 +45,15 @@ func run() throws { try FileManager.default.createDirectory(at: finalOutputURL, withIntermediateDirectories: true) - for file in result.files { + let filesToWrite = skipDefaults ? result.files.filter { $0.name != "Theme+Defaults.swift" } : result.files + + for file in filesToWrite { let fileURL = finalOutputURL.appendingPathComponent(file.name) try file.content.write(to: fileURL, atomically: true, encoding: .utf8) } - print("ThemeKit: Generated \(result.files.count) files in \(finalOutputURL.path):") - for file in result.files { + print("ThemeKit: Generated \(filesToWrite.count) files in \(finalOutputURL.path):") + for file in filesToWrite { print(" - \(file.name)") } } diff --git a/Tests/GeneratedCodeCompilationTests/GeneratedCodeCompilationTests.swift b/Tests/GeneratedCodeCompilationTests/GeneratedCodeCompilationTests.swift new file mode 100644 index 0000000..3c55993 --- /dev/null +++ b/Tests/GeneratedCodeCompilationTests/GeneratedCodeCompilationTests.swift @@ -0,0 +1,47 @@ +import Testing +import GeneratedCodeSwift5 +import GeneratedCodeSwift5MainActor +import GeneratedCodeSwift6 +import GeneratedCodeSwift6MainActor + +/// Verifies that ThemeKit-generated code compiles under all 4 combinations +/// of Swift language mode (5/6) and default actor isolation (nonisolated/MainActor). +/// +/// The primary verification is compilation itself — if these targets build, the +/// generated code is compatible with that configuration. The tests below perform +/// minimal runtime checks to confirm the types are usable. +@Suite("Generated Code Compilation") +struct GeneratedCodeCompilationTests { + + @Test func swift5_typesAreUsable() { + let theme = GeneratedCodeSwift5.Theme.default + _ = theme.colors.surface + _ = theme.gradients.primary + _ = theme.meshGradients.aurora + _ = theme.shadows.card + } + + @Test func swift5MainActor_typesAreUsable() { + let theme = GeneratedCodeSwift5MainActor.Theme.default + _ = theme.colors.surface + _ = theme.gradients.primary + _ = theme.meshGradients.aurora + _ = theme.shadows.card + } + + @Test func swift6_typesAreUsable() { + let theme = GeneratedCodeSwift6.Theme.default + _ = theme.colors.surface + _ = theme.gradients.primary + _ = theme.meshGradients.aurora + _ = theme.shadows.card + } + + @Test func swift6MainActor_typesAreUsable() { + let theme = GeneratedCodeSwift6MainActor.Theme.default + _ = theme.colors.surface + _ = theme.gradients.primary + _ = theme.meshGradients.aurora + _ = theme.shadows.card + } +} diff --git a/Tests/GeneratedCodeSwift5/Theme+Defaults.swift b/Tests/GeneratedCodeSwift5/Theme+Defaults.swift new file mode 100644 index 0000000..7e62590 --- /dev/null +++ b/Tests/GeneratedCodeSwift5/Theme+Defaults.swift @@ -0,0 +1,57 @@ +import SwiftUI +import ThemeKit + +nonisolated extension Theme { + public static let `default` = Theme( + colors: .`default`, + gradients: .`default`, + meshGradients: .`default`, + shadows: .`default` + ) +} + +// MARK: - ThemeColors + +nonisolated extension ThemeColors { + public static let `default` = ThemeColors( + surface: .init(light: Color(red: 1, green: 1, blue: 1), dark: Color(red: 0, green: 0, blue: 0)), + primary: .init(light: Color(red: 0, green: 0.5, blue: 1), dark: Color(red: 0, green: 0.8, blue: 1)) + ) +} + +// MARK: - ThemeGradients + +nonisolated extension ThemeGradients { + public static let `default` = ThemeGradients( + primary: .init( + light: .init(colors: [Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1)]), + dark: .init(colors: [Color(red: 0, green: 0.8, blue: 1), Color(red: 0.3, green: 0, blue: 0.5)]) + ) + ) +} + +// MARK: - ThemeMeshGradients + +nonisolated extension ThemeMeshGradients { + public static let `default` = ThemeMeshGradients( + aurora: .init( + light: .init(width: 2, height: 2, colors: [ + Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.8, blue: 0.8), Color(red: 0, green: 0.8, blue: 0.3), + ]), + dark: .init(width: 2, height: 2, colors: [ + Color(red: 0.3, green: 0, blue: 0.5), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.5, blue: 0.5), Color(red: 0, green: 0.7, blue: 0.5), + ]) + ) + ) +} + +// MARK: - ThemeShadows + +nonisolated extension ThemeShadows { + public static let `default` = ThemeShadows( + card: .init(light: .drop(radius: 4), dark: .drop(radius: 4)), + inner: .init(light: .inner(radius: 2), dark: .inner(radius: 2)) + ) +} diff --git a/Tests/GeneratedCodeSwift5/theme.json b/Tests/GeneratedCodeSwift5/theme.json new file mode 100644 index 0000000..c0634a7 --- /dev/null +++ b/Tests/GeneratedCodeSwift5/theme.json @@ -0,0 +1,12 @@ +{ + "styles": { + "colors": ["surface", {"name": "primary", "style": "primaryColor"}], + "gradients": [{"name": "primary", "style": "primaryGradient"}], + "meshGradients": ["aurora"], + "shadows": ["card", {"name": "inner", "style": "innerGlow"}] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true + } +} diff --git a/Tests/GeneratedCodeSwift5MainActor/Theme+Defaults.swift b/Tests/GeneratedCodeSwift5MainActor/Theme+Defaults.swift new file mode 100644 index 0000000..7e62590 --- /dev/null +++ b/Tests/GeneratedCodeSwift5MainActor/Theme+Defaults.swift @@ -0,0 +1,57 @@ +import SwiftUI +import ThemeKit + +nonisolated extension Theme { + public static let `default` = Theme( + colors: .`default`, + gradients: .`default`, + meshGradients: .`default`, + shadows: .`default` + ) +} + +// MARK: - ThemeColors + +nonisolated extension ThemeColors { + public static let `default` = ThemeColors( + surface: .init(light: Color(red: 1, green: 1, blue: 1), dark: Color(red: 0, green: 0, blue: 0)), + primary: .init(light: Color(red: 0, green: 0.5, blue: 1), dark: Color(red: 0, green: 0.8, blue: 1)) + ) +} + +// MARK: - ThemeGradients + +nonisolated extension ThemeGradients { + public static let `default` = ThemeGradients( + primary: .init( + light: .init(colors: [Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1)]), + dark: .init(colors: [Color(red: 0, green: 0.8, blue: 1), Color(red: 0.3, green: 0, blue: 0.5)]) + ) + ) +} + +// MARK: - ThemeMeshGradients + +nonisolated extension ThemeMeshGradients { + public static let `default` = ThemeMeshGradients( + aurora: .init( + light: .init(width: 2, height: 2, colors: [ + Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.8, blue: 0.8), Color(red: 0, green: 0.8, blue: 0.3), + ]), + dark: .init(width: 2, height: 2, colors: [ + Color(red: 0.3, green: 0, blue: 0.5), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.5, blue: 0.5), Color(red: 0, green: 0.7, blue: 0.5), + ]) + ) + ) +} + +// MARK: - ThemeShadows + +nonisolated extension ThemeShadows { + public static let `default` = ThemeShadows( + card: .init(light: .drop(radius: 4), dark: .drop(radius: 4)), + inner: .init(light: .inner(radius: 2), dark: .inner(radius: 2)) + ) +} diff --git a/Tests/GeneratedCodeSwift5MainActor/theme.json b/Tests/GeneratedCodeSwift5MainActor/theme.json new file mode 100644 index 0000000..c0634a7 --- /dev/null +++ b/Tests/GeneratedCodeSwift5MainActor/theme.json @@ -0,0 +1,12 @@ +{ + "styles": { + "colors": ["surface", {"name": "primary", "style": "primaryColor"}], + "gradients": [{"name": "primary", "style": "primaryGradient"}], + "meshGradients": ["aurora"], + "shadows": ["card", {"name": "inner", "style": "innerGlow"}] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true + } +} diff --git a/Tests/GeneratedCodeSwift6/Theme+Defaults.swift b/Tests/GeneratedCodeSwift6/Theme+Defaults.swift new file mode 100644 index 0000000..7e62590 --- /dev/null +++ b/Tests/GeneratedCodeSwift6/Theme+Defaults.swift @@ -0,0 +1,57 @@ +import SwiftUI +import ThemeKit + +nonisolated extension Theme { + public static let `default` = Theme( + colors: .`default`, + gradients: .`default`, + meshGradients: .`default`, + shadows: .`default` + ) +} + +// MARK: - ThemeColors + +nonisolated extension ThemeColors { + public static let `default` = ThemeColors( + surface: .init(light: Color(red: 1, green: 1, blue: 1), dark: Color(red: 0, green: 0, blue: 0)), + primary: .init(light: Color(red: 0, green: 0.5, blue: 1), dark: Color(red: 0, green: 0.8, blue: 1)) + ) +} + +// MARK: - ThemeGradients + +nonisolated extension ThemeGradients { + public static let `default` = ThemeGradients( + primary: .init( + light: .init(colors: [Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1)]), + dark: .init(colors: [Color(red: 0, green: 0.8, blue: 1), Color(red: 0.3, green: 0, blue: 0.5)]) + ) + ) +} + +// MARK: - ThemeMeshGradients + +nonisolated extension ThemeMeshGradients { + public static let `default` = ThemeMeshGradients( + aurora: .init( + light: .init(width: 2, height: 2, colors: [ + Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.8, blue: 0.8), Color(red: 0, green: 0.8, blue: 0.3), + ]), + dark: .init(width: 2, height: 2, colors: [ + Color(red: 0.3, green: 0, blue: 0.5), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.5, blue: 0.5), Color(red: 0, green: 0.7, blue: 0.5), + ]) + ) + ) +} + +// MARK: - ThemeShadows + +nonisolated extension ThemeShadows { + public static let `default` = ThemeShadows( + card: .init(light: .drop(radius: 4), dark: .drop(radius: 4)), + inner: .init(light: .inner(radius: 2), dark: .inner(radius: 2)) + ) +} diff --git a/Tests/GeneratedCodeSwift6/theme.json b/Tests/GeneratedCodeSwift6/theme.json new file mode 100644 index 0000000..c0634a7 --- /dev/null +++ b/Tests/GeneratedCodeSwift6/theme.json @@ -0,0 +1,12 @@ +{ + "styles": { + "colors": ["surface", {"name": "primary", "style": "primaryColor"}], + "gradients": [{"name": "primary", "style": "primaryGradient"}], + "meshGradients": ["aurora"], + "shadows": ["card", {"name": "inner", "style": "innerGlow"}] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true + } +} diff --git a/Tests/GeneratedCodeSwift6MainActor/Theme+Defaults.swift b/Tests/GeneratedCodeSwift6MainActor/Theme+Defaults.swift new file mode 100644 index 0000000..7e62590 --- /dev/null +++ b/Tests/GeneratedCodeSwift6MainActor/Theme+Defaults.swift @@ -0,0 +1,57 @@ +import SwiftUI +import ThemeKit + +nonisolated extension Theme { + public static let `default` = Theme( + colors: .`default`, + gradients: .`default`, + meshGradients: .`default`, + shadows: .`default` + ) +} + +// MARK: - ThemeColors + +nonisolated extension ThemeColors { + public static let `default` = ThemeColors( + surface: .init(light: Color(red: 1, green: 1, blue: 1), dark: Color(red: 0, green: 0, blue: 0)), + primary: .init(light: Color(red: 0, green: 0.5, blue: 1), dark: Color(red: 0, green: 0.8, blue: 1)) + ) +} + +// MARK: - ThemeGradients + +nonisolated extension ThemeGradients { + public static let `default` = ThemeGradients( + primary: .init( + light: .init(colors: [Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1)]), + dark: .init(colors: [Color(red: 0, green: 0.8, blue: 1), Color(red: 0.3, green: 0, blue: 0.5)]) + ) + ) +} + +// MARK: - ThemeMeshGradients + +nonisolated extension ThemeMeshGradients { + public static let `default` = ThemeMeshGradients( + aurora: .init( + light: .init(width: 2, height: 2, colors: [ + Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.8, blue: 0.8), Color(red: 0, green: 0.8, blue: 0.3), + ]), + dark: .init(width: 2, height: 2, colors: [ + Color(red: 0.3, green: 0, blue: 0.5), Color(red: 0.5, green: 0, blue: 1), + Color(red: 0, green: 0.5, blue: 0.5), Color(red: 0, green: 0.7, blue: 0.5), + ]) + ) + ) +} + +// MARK: - ThemeShadows + +nonisolated extension ThemeShadows { + public static let `default` = ThemeShadows( + card: .init(light: .drop(radius: 4), dark: .drop(radius: 4)), + inner: .init(light: .inner(radius: 2), dark: .inner(radius: 2)) + ) +} diff --git a/Tests/GeneratedCodeSwift6MainActor/theme.json b/Tests/GeneratedCodeSwift6MainActor/theme.json new file mode 100644 index 0000000..c0634a7 --- /dev/null +++ b/Tests/GeneratedCodeSwift6MainActor/theme.json @@ -0,0 +1,12 @@ +{ + "styles": { + "colors": ["surface", {"name": "primary", "style": "primaryColor"}], + "gradients": [{"name": "primary", "style": "primaryGradient"}], + "meshGradients": ["aurora"], + "shadows": ["card", {"name": "inner", "style": "innerGlow"}] + }, + "config": { + "outputPath": ".", + "shouldGeneratePreview": true + } +}